Onboarding & Brand Assets
Pre-login account creation (the whole tenant provisioning flow in one call) and the anytime brand-intake endpoint behind the member console.
| Endpoint | Auth | Rate limit | Body cap |
|---|---|---|---|
POST /api/onboarding |
public | 5/10 min per IP | 6 MB |
POST /api/brand-assets |
session (live effect) | 10/min per IP | 6 MB |
POST /api/onboarding
In live mode: creates the Supabase auth user (email pre-confirmed — onboarding is verification for MVP), provisions and activates the tenant instance, stores the intake, records the Meta path choice, registers brand assets, and nudges the pipeline. Demo mode validates and acks with the same shape.
Request (JSON — all top-level fields optional at parse time)
| Field | Notes |
|---|---|
signup.businessName / email / password / timezone |
password 12 chars minimum, 72 bytes maximum, and at least one letter + one digit required in live mode (WSTG-ATHN-09; the letter/digit rule mirrors Supabase's password_requirements); timezone kept only if valid IANA |
metaPath |
exactly "A" or "B"; anything else → null |
answers |
intake record. Required keys: business_name, what_they_sell, who_they_help, transformation. Optional: current_clients, price_point, past_marketing, unique_approach, proudest_result, in_their_words |
brand.urls / brand.files |
same sanitizer as brand-assets below (max 5 each, images only, 4 MiB/file) |
In live mode signup.businessName is auto-merged into answers.business_name; in demo it must already be in answers.
Responses
| Status | Body |
|---|---|
| 200 (live success) | { "ok": true, "instanceId": "<uuid>", "message": "You're all set — I've got what I need. Sign in with your email and password any time; …" } |
| 200 (live, email already taken) | identical message, "instanceId": "" — deliberately indistinguishable from success except by the empty id (anti-enumeration, #64) |
| 200 (live, short password) | { "ok": false, "instanceId": "", "message": "Please choose a password of at least 12 characters." } — status is still 200; branch on ok |
| 200 (live, long password) | { "ok": false, "instanceId": "", "message": "That password is too long — please keep it under 72 characters." } — over 72 bytes (UTF-8), same branch-on-ok shape |
| 200 (live, no letter or no digit) | { "ok": false, "instanceId": "", "message": "Please include at least one letter and one number in your password." } — mirrors Supabase's password_requirements = "letters_digits", checked here so the caller gets the rule in plain language instead of an opaque failure inside createUser |
| 400 (demo, missing intake) | { "ok": false, "missing": [<unfilled required keys>] } |
| 200 (demo success) | { "ok": true, "instanceId": "demo", "message": ..., "mode": "demo" } |
| 500 (live failure) | honest failure — never falls back to demo (a real auth user may already exist); compensation deletes the half-built user+instance so a clean retry works |
| 411 / 413 / 429 | shared envelopes |
Side effects (live)
createUser (email confirmed) → provisionInstance (status provisioning, notify_email defaults to signup email) → submitIntake → activateInstance (status active — what makes the cron pick the tenant up) → Meta path record (Path B: an account_create approval row for human review; Path A: an onboarding_sessions row awaiting the OAuth adapter) → brand assets registered → pipeline nudge.
Notes for callers
- Retry-safe: replaying a success hits the email collision (neutral 200,
instanceId: ""— no duplicate tenant); after a 500 the compensation path cleaned up, so retry cleanly. - Missing-intake semantics differ by mode: demo → 400 with
missing[]; live → 500 (validated deeper in the flow). Content-Lengthrequired.
POST /api/brand-assets
Add a logo, photos, or links to where the brand lives — anytime after onboarding.
Request (JSON)
{ "instanceId": "...", "urls": ["https://..."], "files": [{ "name": "...", "mediaType": "image/png", "base64": "..." }] }
Sanitizer (shared with onboarding): max 5 URLs (http/https only, normalized, deduped) + 5 files (image/png|jpeg|webp|gif|svg+xml, ≤ 4 MiB decoded). Failures silently dropped. instanceId honored only when RLS-visible to the session user; otherwise the first visible instance is used.
Responses
| Status | Body |
|---|---|
| 200 (live) | { "ok": true, "registered": { "urls": n, "files": n }, "message": "Got it — I've added that to your brand. …" } |
| 200 (demo, keyless only) | same + "mode": "demo" |
| 400 | { "ok": false, "error": "Send JSON with urls and/or files." } or "Nothing usable — add an http(s) link or an image file." |
| 500 (live failure) | { "ok": false, "error": "That didn’t go through — please sign in again and retry." } — no session, schema, or upload failures while configured surface honestly (API slice 0) |
| 411 / 413 / 429 | shared envelopes |
"mode": "demo" now means exactly one thing: the platform is keyless. A configured live failure is an honest 500 — this route no longer falls back to a demo ack, matching onboarding's surface-the-500 contract.
Side effects (live)
Per file: storage upload to brand-assets/{instanceId}/{ts}-{safeName} + brand_assets row (kind: 'logo' when the filename matches /logo/i, else 'photo'). URLs → kind: 'url' rows. Telemetry brand_assets_registered (counts only).
Notes for callers
- Not idempotent — replays insert duplicate rows and re-upload under new timestamped paths (URL dedup is per-request only).
- Compare
registeredcounts against what you sent to detect silent drops.
Source:
app/api/onboarding/route.ts,app/api/brand-assets/route.ts,src/lib/live/onboarding.ts,src/agents/onboarding/index.ts,src/lib/brand/assets.ts