Every FastAPI router in backend/app/api/ on origin/main (July 2026), verified against the code — not the OpenAPI-first design intent. Contract-first tooling: backend/scripts/export_openapi.py → frontend/openapi.json → npm run api:generate → frontend/src/api/generated/schema.d.ts. Feature composables call the typed client (frontend/src/api/client.ts), never hand-built fetch.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health | none | Liveness probe |
| GET | /auth/google/login | none | Start Google OAuth — 302 to Google's consent screen |
| GET | /auth/google/callback | none | OAuth redirect target — verify state, exchange code, issue session cookie, 302 to Today |
| POST | /auth/logout → 204 | session | Clear the session cookie |
| GET | /auth/session | optional | Non-raising login-state probe for the frontend route guard |
| POST | /demo-sessions → 201 | none, IP rate-limited | Create a pre-signup demo session |
| GET | /consents/body-health-data | session | Read the caller's Art. 9 consent state |
| POST | /consents/body-health-data | session | Grant consent |
| DELETE | /consents/body-health-data | session | Revoke consent |
| GET | /daily | session | Mock-mode only — Today band view. Real DailyViewStore not yet implemented (Epic 03) |
| GET | /daily/{day}/details | session | Mock-mode only — All-details numeric transparency view |
| GET | /daily/{day}/context | session | Read the day's optional wellbeing context tags |
| PUT | /daily/{day}/context | session | Replace the day's context tags |
| GET | /daily/{day}/activity-context | session | Read the day's optional movement/turnover context |
| PUT | /daily/{day}/activity-context | session | Replace the day's activity context |
| GET | /meals?date= | session | List logged meals for one local calendar day, oldest first |
| GET | /meals/suggestions?meal_type= | session | Up to 5 "from history" chips for a meal type |
| GET | /meals/search?q= | session | Free-text search across all retained logged-meal history |
| GET | /meals/{meal_id} | session | One logged meal (stored-meal detail screen) |
| GET | /meals/{meal_id}/draft | session | One caller-owned draft (pre-save meal-log detail screen) |
| POST | /meals → 202 | session | Create a draft + queue estimation (the async core loop) |
| POST | /meals/{meal_id}/save → 200 | session | Flip draft→logged, recompute the day's summary |
| POST | /meals/{meal_id}/reestimate → 200 | session | Re-run the AI estimate on a caller-owned draft (max 1×) |
| GET | /profile/today-nutrients | session | Read the Today band personalization set (≤10 nutrients) |
| PUT | /profile/today-nutrients | session | Replace it |
| POST | /billing/checkout → 201 | session | Open a Stripe Checkout session for one market's monthly/annual price |
| POST | /billing/portal → 201 | session | Open a Stripe customer-portal session |
| POST | /billing/webhooks/stripe → 200 | Stripe signature | Idempotent webhook intake (processed_webhook_events) |
No GET /config/prices endpoint exists — pricing is resolved server-side per market locale/interval via BillingCatalogs inside POST /billing/checkout, not fetched as a standalone object by the frontend. No POST /meals/{id}/refine route exists yet — the RefineMeal feature and MealRefineStore boundary are implemented but not wired to a router. Auth endpoints are /auth/google/*, not /auth/{provider}/* generically (Apple would add its own /auth/apple/login|callback pair when 7.1b ships) and not any Auth0-branded path.
Stable from day 1 regardless of what runs behind it — phase 1 drains the AI job in-process via a BackgroundTask after the 202 response (story R1); phase 2 is SQS + a worker Lambda. The frontend is never touched by that swap.
POST /meals {text, meal_type, log_date?, locale, client_queue_id?}
→ 202 { meal_id, status: "processing", reused: false }
(LogMeal: consent check → backdate check (≤7 days, no future) → UsageMeter.check_entitlement
+ check_and_reserve → MealLogStore creates draft + ai_jobs row atomically)
(reused: true short-circuits this — a ≥3×-confirmed MealMemory match returns a complete
draft instantly with reused=true, zero AI call, and the client can skip polling)
GET /meals/{id}/draft
→ 200 { ..., confidence_level: "pending" | ..., nutrients: [] | [...] }
(client polls until confidence_level / nutrients indicate completion)
POST /meals/{id}/save {meal_type?}
→ 200 { meal_id, status: "logged" }
(SaveMeal: draft→logged, MealSaveStore recomputes daily_summaries atomically —
RSS/root-sum-square aggregation)
POST /meals/{id}/reestimate {text?, locale, expected_version?}
→ 200 { meal_id, reestimate_count, version, ...new estimate }
(capped at 1 re-estimate per meal; expected_version guards a stale-edit race)
GET /meals?date=YYYY-MM-DD
→ 200 [MealView, ...] ← always open, logged meals only (drafts are invisible here)
POST /meals (after trial exhausted, no subscription)
→ 402 { detail: "trial_expired" }
The ai_jobs table, its status enum, and the polling endpoint shape stay identical whether the worker runs as a BackgroundTask or a queued Lambda. Frontend ships once.
Social sign-in only — no password ever exists in emealia. Google today (GoogleAuthBoundary, story 7.1a); Apple planned (7.1b) behind the same AuthBoundary shape.
GET /auth/google/login
→ 302 to Google's consent screen
(mints state+nonce, stashed in short-lived httpOnly cookies scoped to /auth,
SameSite=Lax so they survive the cross-site redirect back)
GET /auth/google/callback?code=&state=
→ verify state (constant-time compare) → SocialSignIn.complete(code, nonce)
→ GoogleAuthBoundary exchanges code for a verified identity
→ AuthUserStore claims-or-creates the users row on (auth_provider, provider_subject)
→ issue session cookie (JwtSessionCodec, HS256, httpOnly, Secure, SameSite=Lax)
→ 302 straight to Today — no interstitial
(declined/failed/unavailable all redirect calmly to /login?auth=cancelled|failed|unavailable —
never a raw error page, non-negotiable #6)
POST /auth/logout → 204
(clears the session cookie only; server-side "sign out everywhere" is planned, story 7.4b)
GET /auth/session → { authenticated: bool, user_id?: string }
(non-raising probe for the frontend router guard)
Access+refresh token split with a sessions table + SessionStore boundary, rotation/revocation, and POST /auth/logout-all are story 7.4a/7.4b — not built yet. Today's session is a single HS256 cookie with a fixed TTL (settings.session_ttl_seconds), no refresh/rotation. CLAUDE.md's "access token 15 min / refresh token 30-day rolling" description is the target shape for 7.4a, not the current implementation.
assets/diagrams/auth-session-sequence.mmd — verified against backend/app/api/auth.py + features/social_sign_in.py (story 7.1a, July 2026). Supersedes docs/architecture/diagrams/06-seq-auth.md in the emealia-app repo, which predates story 7.1a and may still assume Auth0.POST /billing/checkout {price_id | interval, locale, success_url, cancel_url}
→ 201 { checkout_url }
(StartCheckout resolves the market's Stripe price id via BillingCatalogs —
one catalog per market locale (de-DE, en-US), each with a monthly + annual Stripe price id)
POST /billing/portal {return_url}
→ 201 { portal_url }
POST /billing/webhooks/stripe (Stripe-signed)
→ 200 { event_id, processed }
(idempotent — a duplicate event id is a primary-key conflict against
processed_webhook_events, treated as already-applied)
Every session-scoped endpoint depends on current_user (raises on missing/invalid session) or optional_user (/auth/session only). All queries are scoped to the resolved UserId — no endpoint accepts a caller-supplied user id. See security-hardening skill + backend/app/api/rate_limit.py / security_headers.py for the cross-cutting middleware (not itemized per-route here).