The HTTP surface between the browser app and the FastAPI backend. The ad is rendered in the browser; the backend persists the result, proxies images, and imports store catalogues.
Base URL (local dev): http://localhost:8000
- IDs are UUID strings; timestamps are ISO-8601 UTC.
- Errors use the error model with an appropriate HTTP status.
- Money is integer cents.
cost_centsis always0(rendering is free);price_centscarries the merchant's product price when set. - The public endpoints (
/proxy-image,/store-products,/store-icons,/save-ad) are per-IP rate limited; over the limit returns429 rate_limited.
multipart/form-data. Stores the rendered video + product image (+ optional logo) in
Supabase Storage and writes the library row with the service-role key, scoped to the user
the token identifies. Safari's sRGB-tagged mp4s are losslessly re-tagged to BT.709 on the
way in (needs ffmpeg on the host; a no-op otherwise).
Form fields
| Field | Type | Notes |
|---|---|---|
video |
file | the rendered MP4 (or WebM) |
token |
string | the user's Supabase access token (validated via /auth/v1/user) |
id |
string | client UUID; sanitised to [A-Za-z0-9_-] before use in any path |
product_title |
string | optional |
target_audience |
string | optional |
aspect_ratio |
string | 16:9 | 1:1 | 4:5 | 9:16 (default 16:9) |
duration_sec |
int | 3–20 (default 10) |
resolution |
string | 1080p |
cost_cents |
int | 0 |
price_cents |
int | product price in cents; 0 when none |
brand_accent |
string | optional hex accent used for the ad |
logo_knockout |
bool | whether the saved logo is a knockout-safe cutout |
image |
file | optional product image |
logo |
file | optional brand logo (best-effort; a failed logo never fails the save) |
Response 200
{ "video_url": "https://<project>.supabase.co/storage/v1/object/public/dart-videos/<uid>/<id>.mp4",
"image_url": "https://.../img-<id>.png" }401 invalid/expired token · 403 the id belongs to another user · 413 upload too
large · 429 rate limited · 500 server missing Supabase config · 502 upload failed.
Re-serves an external product image so the browser can draw it onto the render canvas
without tainting it. SSRF-guarded: scheme + host are validated, and redirects are followed
manually with every hop re-checked against private/loopback/link-local ranges. The body is
streamed and capped (25 MB). The response is served nosniff with a locked-down CSP.
Response 200 — the image bytes (Content-Type: image/*).
400 bad/disallowed URL or non-image · 429 rate limited · 502 fetch failed / too large.
Pulls a merchant's public Shopify products feed (/products.json) so the app can
batch-generate an ad per product — no OAuth, no key. A product-page link (/products/<handle>)
imports just that product; a bare store URL imports the catalogue (up to 100). Falls back to
reading a single product page's JSON-LD / OpenGraph when it isn't a Shopify feed. SSRF-guarded
and rate limited like the image proxy.
Response 200
{ "products": [ { "title": "…", "image": "https://…", "price": "$45.00" } ],
"logo": "https://…/apple-touch-icon.png" }400 missing/invalid store URL · 429 rate limited · 502 couldn't read the link.
Reads the store's homepage <head> for <link rel="icon"|"shortcut icon"|"apple-touch-icon"| "apple-touch-icon-precomposed"> and a <link rel="manifest">'s icons array, so the app can
try the merchant's own (often much higher-resolution) artwork before falling back to a generic
favicon-service guess. SVG sources are skipped (the frontend needs a raster it can read alpha
from). SSRF-guarded and rate limited like the image proxy. Never errors on a store with nothing
declared or an unreachable one — just returns an empty list, so the frontend's existing
icon.horse/Google fallback chain still runs.
Response 200
{ "icons": [ "https://…/apple-touch-icon.png", "https://…/icon-192.png" ] }Largest declared size first; [] when nothing was found. 400 missing/invalid store URL ·
429 rate limited.
Dart emails the 6-digit codes itself (Brevo). Signup creates the Supabase account (admin API, pre-confirmed) only after the code verifies — an unverified signup never exists as an account. Password reset runs the same code flow for accounts that do exist, then sets the new password via the admin API. Sign-in is plain Supabase email+password and never needs a code.
/auth/signup/code { "email": "you@x.com" } → { "sent": true, "request": "…" }
400 invalid_input bad email · 409 conflict email already has an account ·
429 rate_limited per-IP limit or 60s per-address cooldown · 502 email send failed ·
503 Brevo not configured.
/auth/signup/verify { "email", "code", "password", "request" } → { "created": true }
400 invalid_input bad shape or the password fails Supabase's policy ·
400 invalid_code wrong/expired (5 attempts max) or wrong request ·
409 conflict already exists · 429 rate_limited.
/auth/reset/code { "email": "you@x.com" } → { "sent": true, "request": "…" }
Same errors as signup's /code, except 404 not_found when the email has no account.
/auth/reset/check { "email", "code", "request" } → { "valid": true }
Validates the code without consuming it (the UI only asks for a new password after
this passes). Wrong guesses count against the same 5-attempt cap as /verify.
/auth/reset/verify { "email", "code", "password", "request" } → { "reset": true }
Same errors as signup's /verify, plus 404 not_found when the account no longer exists.
request is an opaque token returned by /code to the browser that asked — a guess
without it is rejected before the attempt counter, so third parties can't burn the
owner's 5 attempts. Codes are stored hashed (peppered, purpose-scoped — a signup code
can't verify as a reset code) in auth_codes with a 10-minute TTL.
Both take the session token in the body and re-confirm the password (GoTrue
password grant), so an unattended open tab can't silently change or destroy an account.
/auth/password/code { "token", "current_password" } → { "sent": true, "request": "…" }
Password + session check out → a 6-digit code goes to the account email, so a
leaked password + stolen session still can't change the password.
/auth/password { "token", "current_password", "new_password", "code", "request" } → { "updated": true }
400 invalid_input wrong current password / bad shape / Supabase policy ·
400 invalid_code wrong/expired code · 401 unauthorized bad session · 429 rate_limited.
/auth/delete-account { "token", "password" } → { "deleted": true }
Deletes the library rows and stored files first, then the account (a failure partway
leaves the account intact and retryable). Same errors as /auth/password.
/auth/email/code { "token", "password", "new_email" } → { "sent": true, "request": "…" }
Emails a 6-digit code to the new address (purpose-scoped, same machinery as signup).
400 wrong password / already your email · 409 conflict address taken · 429 cooldown.
/auth/email/verify { "token", "new_email", "code", "request" } → { "updated": true }
Switches the account's login email once the code verifies (admin API, pre-confirmed).
Same code errors as the signup/reset verifies.
/auth/overview { "token" } → { "storage_bytes": 12345678 }
Read-only stats for the account page (no password confirmation needed).
401 unauthorized bad session · 429 rate_limited.
{ "status": "ok",
"save_ad_ready": true,
"video_retag_ready": true,
"signup_email_ready": true,
"monitoring_ready": true,
"providers": { "scraper": "...", "script": "...", "video": "..." } }video_retag_ready is true only when ffmpeg is present and the re-tag is enabled;
signup_email_ready when Brevo is configured (BREVO_API_KEY + AUTH_EMAIL_FROM);
monitoring_ready when Sentry is configured (SENTRY_DSN).
{ "error": { "code": "invalid_url", "message": "Host is not allowed.", "retryable": false } }Codes in use: invalid_url, invalid_input, invalid_code, conflict, scrape_failed,
unauthorized, rate_limited, not_found,
internal.
An earlier server-side pipeline (POST /jobs, GET /jobs/{id}, GET /jobs,
POST /jobs/{id}/regenerate, POST /jobs/{id}/export, GET/POST /settings*) still
exists behind the same app and is covered by tests with all-mock providers, but the
shipped product does not call it — generation and rendering happen in the browser, and
persistence goes through /save-ad. The /jobs routes require a Supabase login when
SUPABASE_URL is set; the /settings routes instead require the operator's
SETTINGS_ADMIN_KEY (via an X-Admin-Key header) and return 404 when that key is
unset. All are rate limited.