API Reference

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.pyfrontend/openapi.jsonnpm run api:generatefrontend/src/api/generated/schema.d.ts. Feature composables call the typed client (frontend/src/api/client.ts), never hand-built fetch.

Endpoint index

MethodPathAuthPurpose
GET/healthnoneLiveness probe
GET/auth/google/loginnoneStart Google OAuth — 302 to Google's consent screen
GET/auth/google/callbacknoneOAuth redirect target — verify state, exchange code, issue session cookie, 302 to Today
POST/auth/logout → 204sessionClear the session cookie
GET/auth/sessionoptionalNon-raising login-state probe for the frontend route guard
POST/demo-sessions → 201none, IP rate-limitedCreate a pre-signup demo session
GET/consents/body-health-datasessionRead the caller's Art. 9 consent state
POST/consents/body-health-datasessionGrant consent
DELETE/consents/body-health-datasessionRevoke consent
GET/dailysessionMock-mode only — Today band view. Real DailyViewStore not yet implemented (Epic 03)
GET/daily/{day}/detailssessionMock-mode only — All-details numeric transparency view
GET/daily/{day}/contextsessionRead the day's optional wellbeing context tags
PUT/daily/{day}/contextsessionReplace the day's context tags
GET/daily/{day}/activity-contextsessionRead the day's optional movement/turnover context
PUT/daily/{day}/activity-contextsessionReplace the day's activity context
GET/meals?date=sessionList logged meals for one local calendar day, oldest first
GET/meals/suggestions?meal_type=sessionUp to 5 "from history" chips for a meal type
GET/meals/search?q=sessionFree-text search across all retained logged-meal history
GET/meals/{meal_id}sessionOne logged meal (stored-meal detail screen)
GET/meals/{meal_id}/draftsessionOne caller-owned draft (pre-save meal-log detail screen)
POST/meals → 202sessionCreate a draft + queue estimation (the async core loop)
POST/meals/{meal_id}/save → 200sessionFlip draft→logged, recompute the day's summary
POST/meals/{meal_id}/reestimate → 200sessionRe-run the AI estimate on a caller-owned draft (max 1×)
GET/profile/today-nutrientssessionRead the Today band personalization set (≤10 nutrients)
PUT/profile/today-nutrientssessionReplace it
POST/billing/checkout → 201sessionOpen a Stripe Checkout session for one market's monthly/annual price
POST/billing/portal → 201sessionOpen a Stripe customer-portal session
POST/billing/webhooks/stripe → 200Stripe signatureIdempotent webhook intake (processed_webhook_events)
Gaps vs. earlier docs

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.

The async meal-logging contract

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" }
Swap invariant

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.

Sequence: client posts free text, API runs consent, backdate and entitlement checks via LogMeal, a draft and AI job are created, a 202 returns immediately, a background worker calls Claude and writes the draft, the client polls the draft, then saves it into daily summaries.
The async core loop, sequence view. Still accurate against the shipped contract above.

Auth / session flow

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)
Roadmap, not shipped

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.

Sequence diagram: user taps Continue with Google, frontend hits /auth/google/login, backend mints state and nonce and redirects to Google, Google redirects back to /auth/google/callback, backend verifies state, exchanges the code via GoogleAuthBoundary, claims or creates the user row via AuthUserStore, issues a signed session cookie via JwtSessionCodec, and redirects straight to Today; also shows /auth/session probing and /auth/logout clearing the cookie.
Google sign-in and session sequence, generated from 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.

Billing flow

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)

Tenancy & auth dependency

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).