openapi.yaml
openapi: 3.1.0
info:
title: Project One API
version: 0.1.0
description: |
AI marketing platform for coaching businesses. Same-origin HTTP API of the Next.js app.
Two operating modes decided by server env (never by the caller): keyless DEMO (successes tagged
`"mode":"demo"`, nothing persisted) and LIVE. All configured live failures fail closed to an
honest 500 — `"mode":"demo"` means keyless only; decisions additionally fail closed on input.
Human-readable deep dives with caller gotchas live in docs/api/resources/*.md; shared behavior
in docs/api/conventions.md. The versioned programmatic surface (/api/v1, RFC 9457 problem+json)
is under the v1 tag.
servers:
- url: /
description: Same origin as the app (canonical origin = PUBLIC_BASE_URL in production)
tags:
- name: leads
- name: bookings
- name: chat
- name: approvals
- name: media
- name: creatives
- name: onboarding
- name: auth
- name: public-pages
- name: internal
- name: v1
description: |
The versioned programmatic surface. Success = the resource JSON directly (no {ok} wrapper);
errors = RFC 9457 application/problem+json with a stable machine `code`. Keyless demo GETs
are labeled (body mode:"demo" + x-p1-mode:demo header); every mutation and any presented
credential on a keyless platform → 503. No CORS by design (server-to-server). Tenancy is
derived from the credential, never from client input. Defaults: 60/min per IP per path +
120/min per instance; mutations 1 MB cap + same-origin Origin assertion (absent Origin
passes). Deep dive: docs/api/resources/platform-api-v1.md.
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: sb-access-token
description: Supabase auth session cookies (sb-*), minted by login/magic-link. Verified via JWKS server-side.
cronSecret:
type: http
scheme: bearer
description: "Authorization: Bearer <CRON_SECRET>, constant-time compared. Unset secret = endpoint disabled."
apiKey:
type: http
scheme: bearer
description: |
v1 only. Format p1_live_ + 43 base62 chars; SHA-256 at rest; scopes read/decide/media:write/admin.
FORMAT SHIPPED, VERIFICATION PENDING: presenting one today → 501 api_keys_not_available
(configured) or 503 platform_not_configured (keyless). Until keys go live, the session
cookie is the only working v1 credential.
schemas:
OkFalseError:
type: object
properties:
ok: { const: false }
error: { type: string }
required: [ok, error]
OkFalseMissing:
type: object
properties:
ok: { const: false }
missing:
type: array
items: { type: string }
required: [ok, missing]
OkFalseReason:
type: object
properties:
ok: { const: false }
reason: { type: string }
required: [ok, reason]
DemoMode:
type: string
const: demo
description: Present on demo-mode successes. Its presence means NOTHING WAS PERSISTED.
Problem:
type: object
description: RFC 9457 problem details (v1 errors). Branch on `code` — title/detail are developer prose and may change.
properties:
type: { type: string, const: "about:blank" }
title: { type: string }
status: { type: integer }
code:
type: string
enum: [invalid_request, unauthorized, forbidden, not_found, rate_limited, payload_too_large, length_required, conflict, platform_not_configured, api_keys_not_available, insufficient_scope, internal_error]
detail: { type: string }
required: [type, title, status, code]
V1Mode:
type: string
enum: [demo, live]
description: demo = keyless fixtures (x-p1-mode:demo header also set); live = your tenant's data.
StatusDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
stage: { type: string, enum: [intake, positioning, angles, scripts, creative, page, launch, live] }
completed: { type: array, items: { type: string, enum: [intake, positioning, angles, scripts, creative, page, launch, live] } }
stages: { type: array, items: { type: string }, description: 'the full ordered stage vocabulary' }
required: [mode, stage, completed, stages]
ApprovalsDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
approvals:
type: array
items:
type: object
properties:
id: { type: string }
action_type: { type: string, description: 'the machine handle (e.g. page_publish, meta_launch)' }
summary: { type: string, description: 'free text for humans' }
required: [id, action_type, summary]
required: [mode, approvals]
CreativesDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
creatives:
type: array
items:
type: object
properties:
id: { type: string }
hook: { type: string }
status: { type: string, enum: [draft, approved, rejected, retired] }
preview_url: { type: string, description: '1-hour signed link — refetch, never store' }
required: [id, hook, status]
required: [mode, creatives]
VideosDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
videos:
type: array
items:
type: object
properties:
id: { type: string }
hook: { type: string }
state: { type: string, enum: [generating, needs_okay, approved, failed] }
video_url: { type: string }
duration_seconds: { type: integer, default: 8 }
required: [id, hook, state, duration_seconds]
candidates:
type: array
description: approved statics eligible for the animate endpoint
items:
type: object
properties:
id: { type: string }
hook: { type: string }
required: [id, hook]
required: [mode, videos, candidates]
PerformanceDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
funnel:
type: [object, 'null']
description: null = no metric row yet (not zeros)
properties:
leads: { type: number }
calls_booked: { type: number }
shows: { type: number }
sales: { type: number }
history:
type: array
description: last 6 diagnosis records, newest first
items:
type: object
properties:
date: { type: string, format: date }
stage: { type: string }
severity: { type: string }
required: [mode, funnel, history]
PositioningDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
positioning:
type: [object, 'null']
description: null = not drafted yet. problem_archetype/confidence_notes are internal and never on the wire.
properties:
status: { type: string, enum: [active, draft] }
version: { type: integer }
niche_statement: { type: string }
core_problem: { type: string }
contrarian_thesis: { type: [string, 'null'] }
core_message:
type: object
properties: { structured: { type: string }, spoken: { type: string } }
ideal_client:
type: object
properties:
description: { type: string }
urgent_problem: { type: string }
pain: { type: string }
already_tried: { type: array, items: { type: string } }
category_of_one:
type: [object, 'null']
properties:
unique_mechanism_name: { type: string }
unique_mechanism: { type: string }
proof: { type: string }
philosophy: { type: string }
identity: { type: string }
tone_of_voice:
type: object
properties:
register: { type: string }
signature_phrases: { type: array, items: { type: string } }
never_say: { type: array, items: { type: string } }
contradictions:
type: array
items: { type: object, properties: { field: { type: string }, description: { type: string } } }
required: [mode, positioning]
AnglesDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
angles:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
moment: { type: string }
one_line: { type: string, description: 'the sentence handed to the Script agent' }
archetype: { type: string, description: 'raw snake_case key — open vocabulary' }
predicted_fit: { type: [integer, 'null'], description: "null = unranked, never a fake 0" }
status: { type: string, enum: [draft, live, in_bank, retired] }
positioning_version: { type: integer }
required: [id, name, moment, one_line, archetype, predicted_fit, status, positioning_version]
required: [mode, angles]
ScriptsDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
chosen_angle_id: { type: [string, 'null'] }
vsl_video_live: { type: boolean }
scripts:
type: array
items:
type: object
description: ad_copy rows carry null structure/format/cta BY DESIGN; vsl rows carry null format + a sections array.
properties:
id: { type: string }
angle_id: { type: string }
type: { type: string, enum: [ad_script, vsl, ad_copy] }
structure: { type: [string, 'null'], description: 'open vocabulary (hook_truth_shift_cta | proof | myth_bust | offer_power | vsl_9section today)' }
format: { type: [string, 'null'], description: 'open vocabulary (short_video | static_concept today)' }
status: { type: string, enum: [draft, approved, retired] }
length_seconds: { type: [integer, 'null'] }
hook: { type: string }
body: { type: [string, 'null'] }
cta: { type: [string, 'null'] }
compliance_checked: { type: boolean }
sections:
type: array
description: vsl rows only — keyed big_promise → offer_cta
items: { type: object, properties: { key: { type: string }, heading: { type: string }, content: { type: string } } }
required: [id, angle_id, type, structure, format, status, length_seconds, hook, body, cta, compliance_checked]
required: [mode, chosen_angle_id, vsl_video_live, scripts]
LandingPageDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
page:
type: [object, 'null']
description: newest lead page; null = not drafted yet
properties:
id: { type: string }
status: { type: string, enum: [draft, published, retired] }
version: { type: integer }
url: { type: [string, 'null'], description: 'null until published — never placeholder prose' }
angle_id: { type: string }
message_match: { type: [boolean, 'null'], description: 'tri-state — null = check not yet run; must be true to publish' }
tracking_installed: { type: boolean }
form_targets: { type: [array, 'null'], items: { type: string } }
published_at: { type: [string, 'null'], format: date-time }
copy:
type: object
properties:
eyebrow: { type: string }
headline: { type: string }
subhead: { type: string }
vsl_caption: { type: string }
accelerators: { type: array, items: { type: string } }
book_now_closer: { type: string }
reassurance: { type: string }
testimonials: { type: array, items: { type: string } }
faqs: { type: array, items: { type: object, properties: { q: { type: string }, a: { type: string } } } }
cta_label: { type: string }
form_fields: { type: array, items: { type: string } }
required: [mode, page]
BrandDto:
type: object
properties:
mode: { $ref: '#/components/schemas/V1Mode' }
brand:
type: [object, 'null']
description: null = not drafted yet. NO storage paths or signed URLs on the wire (private bucket).
properties:
status: { type: string, enum: [active, draft] }
version: { type: integer }
rationale: { type: string }
palette:
type: object
properties:
primary: { $ref: '#/components/schemas/BrandColor' }
secondary: { $ref: '#/components/schemas/BrandColor' }
accent: { $ref: '#/components/schemas/BrandColor' }
ink: { $ref: '#/components/schemas/BrandColor' }
ground: { $ref: '#/components/schemas/BrandColor' }
typography:
type: object
properties: { heading_feel: { type: string }, body_feel: { type: string } }
logo:
type: object
properties: { present: { type: boolean }, clear_space_note: { type: string } }
imagery:
type: object
properties: { style: { type: string }, treatment: { type: string }, subjects: { type: string }, never: { type: string } }
motifs: { type: array, items: { type: string } }
never_look_like: { type: array, items: { type: string } }
provenance:
type: object
description: per-field source/confidence/evidence-note, keys palette/typography/logo/imagery/motifs/never_look_like
additionalProperties: { $ref: '#/components/schemas/BrandProvenance' }
assets:
type: array
description: registered brand assets — present even while brand is null
items:
type: object
properties:
kind: { type: string, description: 'logo | photo | url | meta_asset' }
label: { type: string, description: 'falls back to the URL' }
required: [mode, brand, assets]
BrandColor:
type: object
properties: { hex: { type: string }, usage: { type: string } }
BrandProvenance:
type: object
properties:
source: { type: string, enum: [extracted, inferred, client_stated] }
confidence: { type: string, enum: [high, medium, low] }
note: { type: string }
DecisionDto:
type: object
properties:
id: { type: string }
status: { type: string, enum: [approved, rejected] }
required: [id, status]
AnimateDto:
type: object
properties:
id: { type: string }
animation: { type: string, enum: [queued, already_queued] }
required: [id, animation]
V1DecisionRequest:
type: object
properties:
decision: { type: string, enum: [approve, reject], description: 'exact lowercase; fail-closed — anything else is 400' }
required: [decision]
AngleStatusDto:
type: object
properties:
id: { type: string }
status: { type: string, enum: [retired, in_bank] }
required: [id, status]
V1AngleStatusRequest:
type: object
properties:
status: { type: string, enum: [retired, in_bank], description: "bank housekeeping only — going live is granted via the angle_choice approval, never this endpoint" }
required: [status]
ApprovalRequestDto:
type: object
description: A pending approval REQUEST — payload/payload_hash/Meta ids never on the wire.
properties:
id: { type: string }
action_type: { type: string, enum: [meta_pause, meta_budget] }
status: { type: string, const: pending }
summary: { type: string }
expires_at: { type: string, format: date-time }
required: [id, action_type, status, summary, expires_at]
V1BudgetRequest:
type: object
properties:
daily_budget_cents: { type: integer, minimum: 100, maximum: 1000000, description: "$1–$10,000/day, integer cents, fail-closed" }
required: [daily_budget_cents]
responses:
RateLimited:
description: Per-IP sliding window exceeded.
headers:
retry-after: { schema: { type: integer }, description: 'seconds' }
content:
application/json:
schema: { $ref: '#/components/schemas/OkFalseError' }
example: { ok: false, error: "Too many requests — give it a minute and try again." }
LengthRequired:
description: Content-Length header missing/unparseable (chunked bodies rejected by design).
content:
application/json:
schema: { $ref: '#/components/schemas/OkFalseError' }
example: { ok: false, error: "A Content-Length header is required." }
PayloadTooLarge:
description: Content-Length over the route's cap.
content:
application/json:
schema: { $ref: '#/components/schemas/OkFalseError' }
example: { ok: false, error: "Request body too large." }
V1RateLimited:
description: One of the three windows exceeded (60/min/IP/path, 120/min/instance, or an endpoint-specific limit).
headers:
retry-after: { schema: { type: integer }, description: 'seconds' }
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
example: { type: "about:blank", title: "Too many requests", status: 429, code: rate_limited, detail: "Retry after 60s." }
V1AuthProblems:
description: |
401 unauthorized (configured, no/invalid session) · 403 forbidden (no workspace, or
cross-origin mutation) · 501 api_keys_not_available (bearer presented, keys not live yet) ·
503 platform_not_configured (keyless: any credential, or any mutation).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
V1Internal:
description: Unexpected failure, incl. strict-read DB errors and DTO validation drift — never masked as demo.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
example: { type: "about:blank", title: "Internal error", status: 500, code: internal_error, detail: "Something went wrong on our side." }
paths:
/api/leads:
post:
tags: [leads]
summary: Capture a lead (public; NOT idempotent)
description: Inserts a contact, enrolls on_lead nurture, fires the speed-to-lead owner email. A configured live failure returns an honest 500 — the demo ack appears only on a keyless deployment.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [instanceId, email]
properties:
instanceId: { type: string }
email: { type: string, description: 'lowercased; simple regex validation' }
name: { type: string, maxLength: 500 }
phone: { type: string, maxLength: 500 }
source: { type: string, enum: [page, ad, manual, import], default: page, description: 'unknown values silently coerce to page' }
emailConsent: { type: boolean, default: true }
smsConsent: { type: boolean, default: false }
application/x-www-form-urlencoded:
schema: { type: object }
responses:
'200':
description: Captured (live) or demo ack (keyless only).
content:
application/json:
examples:
live: { value: { ok: true, contactId: "3f7c…", message: "Thanks — you're on the list. Check your email to confirm your time." } }
demoKeyless: { value: { ok: true, contactId: "demo", message: "Thanks — you're on the list.", mode: "demo" } }
'400': { description: 'Missing/invalid fields.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Configured live failure — the lead was NOT captured; retry.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
/api/interest:
post:
tags: [leads]
summary: Register tour interest (idempotent by email)
description: Upserts interest_registrations; both success modes set the p1_tour cookie.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
name: { type: string, maxLength: 120 }
email: { type: string, maxLength: 200 }
business: { type: string, maxLength: 160 }
consent: { type: boolean, default: true }
responses:
'200':
description: Registered (or demo ack, keyless only). Sets p1_tour cookie.
headers:
Set-Cookie: { schema: { type: string }, description: "p1_tour=1; Path=/; Max-Age=31536000; SameSite=Lax; Secure" }
'400': { description: 'Invalid email.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Configured live failure — the registration did NOT persist.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
/api/unsubscribe:
get:
tags: [leads]
summary: Human unsubscribe link (HTML; every outcome 200)
parameters:
- { name: token, in: query, required: true, schema: { type: string }, description: "base64url(contactId:instanceId:HMAC) — stateless, reusable, non-expiring" }
responses:
'200': { description: 'Branded HTML page — success, invalid token, error, and demo differ only in copy (anti-enumeration).' }
'429': { $ref: '#/components/responses/RateLimited' }
post:
tags: [leads]
summary: RFC 8058 one-click unsubscribe (mail clients)
parameters:
- { name: token, in: query, required: true, schema: { type: string } }
responses:
'204': { description: 'Processed — NOTE 204 does not prove persistence (invalid token and live errors also 204).' }
'429': { description: 'Rate limited (no body, no retry-after on this route).' }
/api/bookings:
get:
tags: [bookings]
summary: Open slot set (public, CDN-cached; 60/min per IP + 40/min per instance)
parameters:
- { name: instanceId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: |
Slot set. Cache-Control public,s-maxage=60,swr=300 (live) / s-maxage=300,swr=600 (demo). CORS `*`.
Unknown or non-active (paused/provisioning/offboarded) instances return an honest EMPTY
slots array at 200 (still cached) — never 404, never another tenant's data (WSTG-ATHZ-04).
content:
application/json:
schema:
type: object
properties:
ok: { const: true }
slots: { type: array, items: { type: string, format: date-time } }
durationMin: { type: integer }
timezone: { type: string }
mode: { $ref: '#/components/schemas/DemoMode' }
'400': { description: 'Missing instanceId.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Configured live read failed — honest failure, never fabricated demo slots (uncached).', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
post:
tags: [bookings]
summary: Book a slot (race-safe; NOT idempotent; 10/min per IP + 15/min per instance)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [instanceId, slot, email]
properties:
instanceId: { type: string }
slot: { type: string, description: 'any Date.parse-able form; canonicalized to UTC ISO; must be a member of the current slot set' }
email: { type: string }
name: { type: string, maxLength: 500 }
phone: { type: string, maxLength: 500 }
responses:
'200':
description: Booked (live) or demo ack.
content:
application/json:
examples:
live: { value: { ok: true, message: "You're booked. A confirmation with your calendar invite is on its way.", scheduledAt: "2026-08-01T00:00:00.000Z" } }
'409':
description: |
Slot taken/invalid — body carries a FRESH slot set to re-offer (use it; the GET may be CDN-stale).
A non-active/unknown funnel also 409s with the same reason but an EMPTY slots array
(refused before any write) — empty = funnel not taking bookings, populated = re-offer.
content:
application/json:
schema:
type: object
properties:
ok: { const: false }
reason: { const: slot_unavailable }
slots: { type: array, items: { type: string } }
'400': { description: 'Missing fields.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Configured live failure — the spot was NOT saved; pick a time again. Never a fabricated demo ack.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
options:
tags: [bookings]
summary: CORS preflight
responses:
'204': { description: "Allow-Origin *, Methods GET/POST/OPTIONS, Headers content-type only." }
/api/bookings/manage:
get:
tags: [bookings]
summary: Lead's reschedule/cancel page (HTML; every outcome 200)
parameters:
- { name: token, in: query, required: true, schema: { type: string }, description: "base64url(bookingId:instanceId:HMAC('book:'…)) — reusable capability URL" }
responses:
'200': { description: 'HTML page; outcome only distinguishable by content.' }
'429': { $ref: '#/components/responses/RateLimited' }
post:
tags: [bookings]
summary: Apply cancel/reschedule (form post from the page; HTML result)
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [token, action]
properties:
token: { type: string }
action: { type: string, enum: [cancel, reschedule] }
slot: { type: string, description: 'required for reschedule; same membership rules as booking' }
responses:
'200': { description: 'HTML page (Cancelled / Moved / That time was just taken / errors).' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/bookings/mark:
post:
tags: [bookings]
summary: Host marks a call outcome (idempotent; 30/min per IP, 1 MB cap)
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [bookingId, outcome]
properties:
bookingId: { type: string }
outcome: { type: string, enum: [showed, no_show] }
responses:
'200': { description: 'Marked (repeat posts are no-op successes). no_show triggers recovery nurture once.' }
'400': { description: 'Bad shape.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
'404': { description: 'Not visible under RLS (covers not-yours; anti-enumeration).' }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Live failure — never masked as demo success.' }
/api/conductor:
post:
tags: [chat]
summary: Member chat (live LLM or demo responder)
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message: { type: string, maxLength: 6000 }
responses:
'200':
description: Reply. `mode` tells you which path answered (live failures fall back to demo).
content:
application/json:
schema:
type: object
properties:
reply: { type: string }
mode: { type: string, enum: [live, demo] }
'400': { description: "Message over 6000 chars — bare {error} envelope (no ok field)." }
'401': { description: "Platform configured but no session — {error: Please sign in to chat.}" }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/approvals:
post:
tags: [approvals]
summary: Decide a pending approval (fail-closed; single-use; 30/min per IP, 1 MB cap)
description: Approve mints a server-side one-time capability token (never returned). Second decide on the same id → honest 500 when configured (404 only in keyless demo). Expired approvals are rejected at decision time (row flips to 'expired', no token minted). Document gates activate inline; a post-approve activation failure returns ok:false with status "approved" (honesty rule).
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [approvalId, decision]
properties:
approvalId: { type: string }
decision: { type: string, enum: [approve, reject], description: 'exact lowercase; anything else is 400' }
responses:
'200':
description: Decision recorded (live) or demo reply (only apv_demo_page_edit exists in demo).
content:
application/json:
examples:
live: { value: { ok: true, status: approved, message: "On it — I'll take it from here and let you know how it goes." } }
activationFailed: { value: { ok: false, status: approved, message: "I saved your approval, but hit a snag applying it — please try that once more in a moment." } }
'400': { description: 'Fail-closed validation.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
'404': { description: 'Unknown approval (keyless demo only).', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseReason' } } } }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Configured live failure — covers no-session, already-decided, expired, and DB errors.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
/api/media:
post:
tags: [media]
summary: "Media actions: photos | vsl_ticket | vsl_confirm (6MB cap)"
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- title: photos
type: object
required: [action, files]
properties:
action: { const: photos }
files:
type: array
maxItems: 8
items:
type: object
properties:
name: { type: string }
mediaType: { type: string, enum: [image/png, image/jpeg, image/webp] }
base64: { type: string, description: '≤4MB decoded; invalid entries silently dropped' }
- title: vsl_ticket
type: object
required: [action, filename, contentType, sizeBytes]
properties:
action: { const: vsl_ticket }
filename: { type: string }
contentType: { type: string, enum: [video/mp4, video/quicktime, video/webm] }
sizeBytes: { type: integer, maximum: 536870912 }
- title: vsl_confirm
type: object
required: [action, path]
properties:
action: { const: vsl_confirm }
path: { type: string, description: 'the exact path returned by vsl_ticket' }
responses:
'200': { description: "photos: {registered:{photos}} · vsl_ticket: {path,token,bucket} · vsl_confirm: success message. Demo adds mode:'demo'." }
'400': { description: "Validation — photos/parse use {error}; vsl actions use {reason: bad_type|too_big|bad_name|bad_path|not_uploaded}." }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Live failure (incl. unauthenticated) — honest, never demo-masked. SchemaNotReadyError degrades to demo instead.' }
/api/vsl:
get:
tags: [media]
summary: Stable VSL redirect (public)
parameters:
- { name: i, in: query, required: true, schema: { type: string, format: uuid } }
responses:
'302': { description: "location: fresh 1h signed URL; cache-control: no-store. NEVER persist the target URL." }
'404': { description: 'plain-text "Not found" — identical for bad id / no video / demo / internal error (anti-enumeration).' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/creatives:
post:
tags: [creatives]
summary: Approve/reject a creative (statics AND generated videos; 30/min per IP, 1 MB cap)
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [creativeId, decision]
properties:
creativeId: { type: string }
decision: { type: string, enum: [approve, reject] }
responses:
'200': { description: 'Decided (same-decision repeats are no-ops; flips allowed). Demo acks any id with mode:''demo''.' }
'400': { description: 'Bad shape.' }
'404': { description: "Catch-all live failure: {error: not_found | already_live | update_failed} — fixed tokens, raw DB messages never returned." }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Live throw (e.g. no session) — honest failure.' }
/api/videos:
post:
tags: [creatives]
summary: Animate an approved static into a draft video (idempotent per source)
description: Seeds a kind='video' draft; generation happens in the cron tick (budget-capped, GEMINI_API_KEY-gated). Exactly one video per source static — duplicates return already:true.
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [creativeId]
properties:
creativeId: { type: string, description: 'id of the APPROVED kind=''static'' source' }
responses:
'200':
description: Seeded / already in flight / demo ack. No videoId is returned — poll the videos screen reader.
content:
application/json:
examples:
new: { value: { ok: true, already: false, message: "On it — I'll animate that ad from your photo. It'll appear here for your okay before anything runs." } }
duplicate: { value: { ok: true, already: true, message: "Already on it — that ad's video is in the works." } }
'400': { description: "Preconditions: not found / not a static / not approved / fixed insert-failure string (DB detail never returned)." }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Live throw — honest failure.' }
/api/onboarding:
post:
tags: [onboarding]
summary: Full tenant provisioning (public; 6MB cap)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
signup:
type: object
properties:
businessName: { type: string }
email: { type: string }
password: { type: string, minLength: 12, description: 'required in live mode; max 72 bytes UTF-8 (WSTG-ATHN-09)' }
timezone: { type: string, description: 'IANA; dropped if invalid' }
metaPath: { type: string, enum: [A, B] }
answers:
type: object
description: "required keys: business_name, what_they_sell, who_they_help, transformation"
brand:
type: object
properties:
urls: { type: array, maxItems: 5, items: { type: string } }
files: { type: array, maxItems: 5, items: { type: object } }
responses:
'200':
description: |
Success ({instanceId: uuid}) OR email-collision (identical message, instanceId:"" — anti-enumeration)
OR password out of policy (<12 chars or >72 bytes: {ok:false} with status 200 — branch on `ok`).
Demo: instanceId "demo" + mode.
'400': { description: "Demo mode only: missing required intake keys.", content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseMissing' } } } }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Live failure (never falls back to demo; compensation cleans up — retry is safe).' }
/api/brand-assets:
post:
tags: [onboarding]
summary: Add brand assets (6MB cap; live failures surface as honest 500)
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
instanceId: { type: string, description: 'honored only when RLS-visible to the session' }
urls: { type: array, maxItems: 5, items: { type: string } }
files:
type: array
maxItems: 5
items:
type: object
properties:
name: { type: string }
mediaType: { type: string, description: 'image/png|jpeg|webp|gif|svg+xml' }
base64: { type: string, description: '≤4MiB decoded' }
responses:
'200': { description: "Registered counts. mode:'demo' present = keyless deployment (nothing written). NOT idempotent." }
'400': { description: 'Unparseable JSON or nothing survived sanitization.' }
'411': { $ref: '#/components/responses/LengthRequired' }
'413': { $ref: '#/components/responses/PayloadTooLarge' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { description: 'Configured live failure (no session, schema, upload) — honest, never demo-masked.', content: { application/json: { schema: { $ref: '#/components/schemas/OkFalseError' } } } }
/api/auth/login:
post:
tags: [auth]
summary: Sign in (303 redirect surface — disable redirect-following to inspect)
requestBody:
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [email]
properties:
email: { type: string }
password: { type: string }
mode: { type: string, description: "'link' = magic-link; ANY other value = password mode" }
responses:
'303':
description: |
Location vocabulary: /home (+Set-Cookie sb-*) = password success ·
/login?error=credentials|email|demo · /login?sent=1 (link mode live — ALWAYS, anti-enumeration) · /login?sent=demo.
'429': { $ref: '#/components/responses/RateLimited' }
/api/auth/logout:
post:
tags: [auth]
summary: Sign out (always 303 → /; idempotent)
responses:
'303': { description: 'Redirect to / with session-clearing Set-Cookie when a session existed; sb-* cookies are force-expired even if server-side signOut fails (WSTG-SESS-06).' }
/auth/confirm:
get:
tags: [auth]
summary: Magic-link landing (token single-use — prefetching burns it)
parameters:
- { name: token_hash, in: query, required: true, schema: { type: string } }
- { name: type, in: query, schema: { type: string, default: email } }
- { name: next, in: query, schema: { type: string }, description: 'sanitized to a relative in-app path; else /home' }
responses:
'303': { description: "Success → ${next} (+Set-Cookie) · any failure → /login?error=expired · demo → /login?sent=demo." }
/p/{instanceId}:
get:
tags: [public-pages]
summary: Served lead page (raw HTML, newest row wins)
parameters:
- { name: instanceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: "text/html. Drafts add x-robots-tag: noindex. No caching yet (issue #104)." }
'404': { description: "text/plain 'This page is still being put together.' — ambiguous by design." }
'429': { $ref: '#/components/responses/RateLimited' }
/p/{instanceId}/thanks:
get:
tags: [public-pages]
summary: Served thank-you page (independent of the lead page)
parameters:
- { name: instanceId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: 'text/html (same semantics as the lead page).' }
'404': { description: 'Same ambiguous 404.' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/cron:
post:
tags: [internal]
summary: One platform tick (idempotent; secret-gated)
security: [{ cronSecret: [] }]
responses:
'200':
description: |
Live: {ok, instances, pipelineActions[], pipelineErrors, funnelErrors, nurtureSent} —
a 200 does NOT mean zero per-instance failures; inspect the error counts.
Configured-secret-but-keyless: {ok:true, mode:'demo', note:'platform not configured'}.
'401': { description: 'plain-text "unauthorized" (wrong/missing bearer, or CRON_SECRET unset).' }
'500': { description: 'plain-text "cron job error" (also raised by a weak/placeholder CRON_SECRET).' }
# ── The versioned programmatic surface ─────────────────────────────────────
# Demo GETs additionally set the x-p1-mode:demo response header; the body `mode` field is
# authoritative either way. No cache-control, no pagination, no query params on any v1 GET.
/api/v1/status:
get:
tags: [v1]
summary: Pipeline position
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: Where the tenant's pipeline sits. Demo shows a fixed mid-pipeline position.
content:
application/json:
schema: { $ref: '#/components/schemas/StatusDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/approvals:
get:
tags: [v1]
summary: Pending approvals (poll this, then decide — the core integration loop)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: Pending approvals. `action_type` is the machine handle; `summary` is human prose.
content:
application/json:
schema: { $ref: '#/components/schemas/ApprovalsDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/creatives:
get:
tags: [v1]
summary: Image ads with 1-hour preview links (refetch, never store)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: Creatives list. A live reader that isn't ready yields an empty array at 200, not an error.
content:
application/json:
schema: { $ref: '#/components/schemas/CreativesDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/videos:
get:
tags: [v1]
summary: Videos + animate candidates
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: Videos (poll `state` for generation progress) and approved statics eligible for animate.
content:
application/json:
schema: { $ref: '#/components/schemas/VideosDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/performance:
get:
tags: [v1]
summary: Funnel counts + diagnosis history (strict reads — a DB blip is a 500, never a silent empty)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: funnel is null when no metric row exists yet (not zeros); history = last 6 diagnoses.
content:
application/json:
schema: { $ref: '#/components/schemas/PerformanceDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/positioning:
get:
tags: [v1]
summary: Active/draft positioning document (null = not drafted yet)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: The active document, else the newest ready draft, else positioning null.
content:
application/json:
schema: { $ref: '#/components/schemas/PositioningDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/angles:
get:
tags: [v1]
summary: The full angle bank, raw vocabulary (ranked by predicted_fit)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: Raw four-value status vocabulary — no UI 'bank' merge; archetype is the raw key.
content:
application/json:
schema: { $ref: '#/components/schemas/AnglesDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/scripts:
get:
tags: [v1]
summary: Script rows + VSL for the chosen angle (ad_copy rows carry nulls by design)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: Filtered to the live angle when one exists; vsl rows carry keyed sections.
content:
application/json:
schema: { $ref: '#/components/schemas/ScriptsDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/landing-page:
get:
tags: [v1]
summary: The newest lead page (url null until published; message_match tri-state)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: page null before the first draft; html deliberately off the wire.
content:
application/json:
schema: { $ref: '#/components/schemas/LandingPageDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/brand:
get:
tags: [v1]
summary: Brand board + registered assets (no storage paths on the wire)
security: [{ sessionCookie: [] }, { apiKey: [] }, {}]
responses:
'200':
description: brand null until drafted; assets present either way. logo.present replaces bucket paths.
content:
application/json:
schema: { $ref: '#/components/schemas/BrandDto' }
'401': { $ref: '#/components/responses/V1AuthProblems' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'501': { $ref: '#/components/responses/V1AuthProblems' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/approvals/{id}/decision:
post:
tags: [v1]
summary: Decide an approval (fail-closed; NOT idempotent — repeat decide → 500, verify via GET)
description: |
Terminal-state write. Already-decided and expired approvals surface as 500 internal_error
(expired flips the row to 'expired', no token minted). A 500 titled "Approval saved, apply
failed" means the decision persisted but applying it failed — retry shortly applies it.
Mutations: 1 MB cap, Content-Length required (411), same-origin Origin assertion (403).
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/V1DecisionRequest' }
responses:
'200':
description: Decided. The capability token is structurally stripped and never leaks.
content:
application/json:
schema: { $ref: '#/components/schemas/DecisionDto' }
'400':
description: Bad decision value or missing id — instruction in `detail`.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'404':
description: Not visible under RLS, or belongs to another of the caller's instances (v1 mutates only what v1 reads — anti-enumeration).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/creatives/{id}/decision:
post:
tags: [v1]
summary: Decide a creative (same-decision repeat = no-op success; flips allowed)
description: A status='live' creative refuses with 404 (detail 'already_live' — running ads change through the approval gate); 'not_found' and 'update_failed' are the other 404 detail tokens.
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/V1DecisionRequest' }
responses:
'200':
description: Decided.
content:
application/json:
schema: { $ref: '#/components/schemas/DecisionDto' }
'400':
description: Bad decision value or missing id.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'404':
description: "detail: not_found | already_live | update_failed (fixed tokens)."
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/approvals/pause-request:
post:
tags: [v1]
summary: "Request a campaign pause (request-only: creates a pending approval; no body, send Content-Length: 0)"
description: |
Targets the newest ACTIVE launched campaign, resolved server-side. One pending pause per
instance — an identical repeat returns the existing row (200); a changed target supersedes
it. A DECIDED pause is recorded, not executed, until the Meta Graph adapters land.
security: [{ sessionCookie: [] }]
responses:
'201':
description: Created — the pending approval (also visible in GET /api/v1/approvals).
content:
application/json:
schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
'200':
description: Deduped identical repeat — the SAME pending row, not a twin.
content:
application/json:
schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'409':
description: "code 'conflict': nothing is live to pause — no campaign launched yet."
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'411': { description: "Content-Length required even though there is no body — send Content-Length: 0.", content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/approvals/budget-request:
post:
tags: [v1]
summary: "Request a daily-budget change (request-only: creates a pending approval)"
description: |
Targets the newest Meta-side ad set, resolved server-side. A CHANGED amount supersedes the
previous pending request (old row → expired) — one pending budget change per instance, so
two amounts are never simultaneously decidable. Decided ≠ executed until Graph adapters land.
security: [{ sessionCookie: [] }]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/V1BudgetRequest' }
responses:
'201':
description: Created — the pending approval.
content:
application/json:
schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
'200':
description: Deduped identical repeat — the SAME pending row.
content:
application/json:
schema: { $ref: '#/components/schemas/ApprovalRequestDto' }
'400':
description: daily_budget_cents missing/non-integer/out of 100..1000000.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'409':
description: "code 'conflict': no ad set exists yet — budget changes start after launch."
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/angles/{id}:
patch:
tags: [v1]
summary: Angle bank housekeeping — retire / re-bank (activation stays with the angle_choice approval)
description: |
Idempotent repeat; the write is conditional on the observed status, so a concurrent
angle_choice activation can never be silently overwritten (0 rows matched → 409).
The live angle refuses with 409 conflict — replace it via the angle flow first.
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/V1AngleStatusRequest' }
responses:
'200':
description: Changed (or already there — no-op success).
content:
application/json:
schema: { $ref: '#/components/schemas/AngleStatusDto' }
'400':
description: status not retired/in_bank (incl. live/draft) — detail points at the angle_choice approval.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'404':
description: Not RLS-visible, or belongs to another of the caller's instances (v1 acts on the credential-derived instance only).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'409':
description: "code 'conflict': the angle is live, or the state moved between read and write."
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'411': { description: 'Content-Length required.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'413': { description: 'Over 1 MB.', content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'503': { $ref: '#/components/responses/V1AuthProblems' }
/api/v1/creatives/{id}/animate:
post:
tags: [v1]
summary: "Animate an approved static (idempotent per source; NO body — but send Content-Length: 0)"
description: |
Seeds a kind='video' draft; generation happens in the cron tick (GEMINI_API_KEY-gated).
Poll GET /api/v1/videos for state transitions, then approve via the creative decision
endpoint. Duplicate calls hit the one-video-per-source index → animation:'already_queued'.
Tightest limit here: 10/min per instance (endpoint-specific, on top of the defaults).
NOTE: not-found/not-a-static/not-approved preconditions are 400 invalid_request, not 404.
security: [{ sessionCookie: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Seeded or already in flight.
content:
application/json:
schema: { $ref: '#/components/schemas/AnimateDto' }
'400':
description: Precondition failed — not found / not a static / not approved / could-not-start (fixed strings in `detail`).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'403': { $ref: '#/components/responses/V1AuthProblems' }
'411': { description: "Content-Length required even though there is no body — send Content-Length: 0.", content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'429': { $ref: '#/components/responses/V1RateLimited' }
'500': { $ref: '#/components/responses/V1Internal' }
'503': { $ref: '#/components/responses/V1AuthProblems' }