API Design

The API exposes the domain through the api-gateway. Public clients never call service APIs or databases directly.

Each service carries a machine-readable version of its part of this contract in services/<name>/openapi.yaml; this page remains the system-wide source they are derived from.

1. Conventions

  • Base path: /api/v1
  • JSON request and response bodies
  • Consumer authentication: bearer token when signed in; anonymous scans use a stable Visitor ID managed by the client and gateway
  • Brand scope comes from the authenticated Brand User; public requests never supply a trusted brandId
  • Error body: { "code": "...", "message": "...", "correlationId": "..." }

Identity headers (gateway → services)

Services never see raw tokens. The gateway validates the bearer token against the Keycloak realm's JWKS (ADR-011) and forwards the result as trusted headers:

  • It always strips any inbound X-Account-Id, then injects it from the token's sub only after successful validation — a client can never assert an account itself.
  • A present-but-invalid token (bad signature, expired, wrong issuer) is rejected with 401, never silently downgraded to anonymous.
  • /api/v1/me/* (the consumer-only surface) requires a valid token and returns 401 without one. POST /api/v1/scans, /api/v1/accounts and /api/v1/sessions are public; a valid token on a scan is honoured (account-scoped) but not required.
  • X-Visitor-Id carries the ADR-004 pseudonymous identity; the server-set visitor cookie is authoritative over a client-sent header.

2. Public API

Scanning and passports

Method Path Auth Purpose
GET /01/{gtin}/10/{lot}/21/{serial}?17={expiry} None Resolver entry: a camera app opening the QR's Digital Link URL lands here; the gateway parses the path, creates the scan, and redirects the browser to the result page
POST /api/v1/scans Optional Record a scan and resolve its Product Passport
GET /api/v1/scans/{scanId} Owner Read the progressively assembled result
POST /api/v1/products/{gtin}/requests None Report an unknown product

There are two ways into a scan: the in-app scanner decodes the GS1 Digital Link client-side and calls POST /api/v1/scans; a camera app opens the URL itself and arrives at the Resolver route, which performs the same scan creation server-side. Lot, serial and expiry are optional in both.

A scan belongs to the Visitor ID or Account that created it. GET /scans/{scanId} returns 404 to anyone else — the Verdict is health-derived and must never be readable by a third party holding the scan id.

POST /api/v1/scans
{
  "gtin": "08012345678901",
  "lot": "LOT-42",
  "serial": "ITEM-9",
  "expiry": "2026-07-15"
}
{
  "scanId": "scan_123",
  "catalogEntry": {
    "gtin": "08012345678901",
    "name": "Example Product",
    "brand": "Example Brand",
    "digitalLabel": {}
  },
  "verdict": {
    "grade": "careful",
    "reasons": [],
    "goalFit": "Fits your primary goal"
  },
  "journey": { "state": "pending" },
  "eco": { "state": "pending" }
}

Anonymous responses omit verdict. GET /scans/{scanId} returns updated section states: pending, loaded, or failed.

Method Path Auth Purpose
POST /api/v1/accounts None Create an Account and link the current Visitor ID
POST /api/v1/sessions None Sign in
DELETE /api/v1/sessions/current Consumer Sign out
PUT /api/v1/me/consents/health-profile Consumer Grant health-profile consent
DELETE /api/v1/me/consents/health-profile Consumer Revoke consent and erase the Health Profile
GET /api/v1/me/health-profile Consumer Read the Health Profile
PUT /api/v1/me/health-profile Consumer Create or replace the consent-gated Health Profile
DELETE /api/v1/me/health-profile Consumer Delete the Health Profile without deleting the Account
DELETE /api/v1/me/account Consumer Delete the Account and start the full erasure process

Fridge

Method Path Auth Purpose
GET /api/v1/me/fridge Consumer Read Fridge items with derived Freshness
POST /api/v1/me/fridge/items Consumer Add a physical item from a scan
PATCH /api/v1/me/fridge/items/{itemId} Consumer Mark an item consumed or discarded
DELETE /api/v1/me/fridge/items/{itemId} Consumer Remove an item
GET /api/v1/me/fridge/waste-summary Consumer Read the monthly waste projection

Adding an item uses { "scanId": "scan_123" }; the Fridge service obtains the trusted item and passport snapshot through the internal Passport API.

Brand dashboard

Method Path Auth Purpose
GET /api/v1/brand/products Brand User List the authenticated Brand's products
POST /api/v1/brand/products Brand User Onboard a Product Catalog Entry
GET /api/v1/brand/metrics/scans Brand User Read aggregate scan counts and trends
GET /api/v1/brand/metrics/engagement Brand User Read aggregate save and account-conversion metrics

The gateway derives Brand scope from the authenticated user. Brand endpoints cannot request another Brand's identifier or access consumer endpoints.

3. Internal Service APIs

Internal APIs are not exposed through the public gateway.

Service Internal endpoint Used by
passport-service GET /internal/v1/scans/{scanId}/item Fridge obtains trusted ScannedItem and passport snapshot
personalization-service POST /internal/v1/verdicts Passport requests an optional Verdict using Account reference + Digital Label
identity-service GET /internal/v1/accounts/{accountId}/consents/health-profile Personalization checks consent before creating a Health Profile

Measurement, cross-context reactions, and erasure propagation use the domain events defined in Domain Events, not public REST endpoints.

4. Endpoint Ownership

Public prefix Gateway routes to
/01/… (Resolver entry) passport-service
/api/v1/scans, /api/v1/products passport-service
/api/v1/accounts, /api/v1/sessions, /api/v1/me/consents, /api/v1/me/account identity-service
/api/v1/me/health-profile personalization-service
/api/v1/me/fridge fridge-service
/api/v1/brand/products passport-service, with Brand scope enforced
/api/v1/brand/metrics brand-analytics-service, with Brand scope enforced