{"openapi":"3.1.0","info":{"title":"ShelfAtlas Catalog API","version":"1.0.0","description":"Read access to normalised Danish retail catalog data — products, offers, stores, and chains. Authenticate with a Bearer `sa_live_` API key. This document is the contract the official ShelfAtlas SDKs conform to. Data coverage: the offer feed covers every major Danish chain, but the curated product catalog is smaller and actively expanding — an empty data array from /products/{id}/offers or /price-history means no offers are linked to that product yet, not an error.","termsOfService":"https://shelfatlas.com/legal/terms"},"servers":[{"url":"https://api.shelfatlas.com","description":"Production"}],"security":[{"bearerAuth":[]}],"paths":{"/api/v1/public/catalog/products":{"get":{"operationId":"listProducts","summary":"List products","tags":["Products"],"parameters":[{"name":"chain_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Restrict to a single chain by slug."},{"name":"brand_id","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated list of UUIDs (1–100). Match products by brand."},{"name":"brand_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Restrict to a single brand by slug — the readable alternative to brand_id, which requires an internal UUID."},{"name":"category_id","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated list of UUIDs (1–100). Match products by primary category."},{"name":"ids","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated list of UUIDs (1–100). Match products by id."},{"name":"exclude_ids","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated list of UUIDs (1–100). Exclude these product ids."},{"name":"curated","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Only curated catalog products, skipping rows auto-created during ingest."},{"name":"has_image","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Only products that have (true) or lack (false) a product image."},{"name":"q","in":"query","required":false,"schema":{"type":"string","minLength":2,"maxLength":64},"description":"Case-insensitive substring search on the product name. Minimum two characters."},{"name":"updated_since","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Only products updated at or after this timestamp (filters on updatedAt). Accepts a bare date (`2026-08-21`) or a full ISO 8601 datetime. Built for incremental sync: poll with the newest updatedAt you have already ingested instead of re-fetching the whole catalog on every call. Composes with every other filter and with cursor pagination."},{"name":"include_expired","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Offer lists are live-only by default: only offers whose validTo has not passed (current and upcoming campaigns). Pass `true` to include expired offers as well (price history). Any other value is a 400 invalid_params."},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"],"maxLength":512},"description":"Opaque pagination cursor returned in a previous response."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Page size (1–200, default 50)."}],"responses":{"200":{"description":"A page of products.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Product"}},"pagination":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":"integer","description":"The effective page size."},"cursor":{"type":["string","null"],"description":"Opaque cursor for the next page; null on the last page."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/ChainNotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/products/{id}":{"get":{"operationId":"getProduct","summary":"Get a product by id or EAN/GTIN","description":"Accepts either the ShelfAtlas product UUID or a bare EAN/GTIN (8, 13 or 14 digits) — the identifier an external catalog actually holds. Detection is by format; the shapes are disjoint.","tags":["Products"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|\\d{8}|\\d{13}|\\d{14})$"},"description":"Product UUID, or EAN/GTIN (8/13/14 digits)."}],"responses":{"200":{"description":"The product.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Product"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/products/{id}/offers":{"get":{"operationId":"listProductOffers","summary":"List offers matched to a product","description":"Only offers matched to the curated product are returned. The catalog is expanding — an empty page means no offers are linked to this product yet; it is not an error and does not mean the product is inactive.","tags":["Products","Offers"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"include_expired","in":"query","required":false,"schema":{"type":"string","enum":["true","false"]},"description":"Offer lists are live-only by default: only offers whose validTo has not passed (current and upcoming campaigns). Pass `true` to include expired offers as well (price history). Any other value is a 400 invalid_params."},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"],"maxLength":512},"description":"Opaque pagination cursor returned in a previous response."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Page size (1–200, default 50)."}],"responses":{"200":{"description":"A page of offers for the product.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Offer"}},"pagination":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":"integer","description":"The effective page size."},"cursor":{"type":["string","null"],"description":"Opaque cursor for the next page; null on the last page."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/offers":{"get":{"operationId":"listOffers","summary":"List offers","description":"Filter by a single product, a batch of products, a brand, or a chain. product_id / product_ids / brand_slug / chain_slug cannot be combined.","tags":["Offers"],"parameters":[{"name":"product_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Offers matched to a single product."},{"name":"product_ids","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated list of UUIDs (1–100). Offers matched to a batch of products."},{"name":"brand_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Every current offer matched to a product of this brand — the one-call form of product_ids."},{"name":"chain_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Restrict to a single chain by slug."},{"name":"ingested_since","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Only offers ingested at or after this timestamp (filters on ingestedAt). Accepts a bare date (`2026-08-21`) or a full ISO 8601 datetime. Built for incremental sync: poll with the newest ingestedAt you have already stored instead of re-fetching the whole catalog on every call. Composes with every other filter and with cursor pagination."},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"],"maxLength":512},"description":"Opaque pagination cursor returned in a previous response."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Page size (1–200, default 50)."}],"responses":{"200":{"description":"A page of offers.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Offer"}},"pagination":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":"integer","description":"The effective page size."},"cursor":{"type":["string","null"],"description":"Opaque cursor for the next page; null on the last page."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/ChainNotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/offers/{id}":{"get":{"operationId":"getOffer","summary":"Get an offer by id","tags":["Offers"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The offer.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Offer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/offers/search":{"get":{"operationId":"searchOffers","summary":"Search raw offer titles, grouped by product or item key","description":"Danish full-text + typo-tolerant (trigram) search over raw catalog-offer titles, grouped by the curated product when one is known and otherwise by a stable item key. `key` is OPAQUE — `p:<product_id>` or `k:<item_key>` — compare it, never parse it. `item_key` is the group's persisted item identity and is what you pass to GET /offers/history. Supplying lat+lng adds the nearest store per chain and its shelf-status, and orders offers by a worth-the-trip score.","tags":["Offers"],"parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2,"maxLength":64},"description":"Search text, e.g. \"pepsi max\"."},{"name":"lat","in":"query","required":false,"schema":{"type":"number","minimum":-90,"maximum":90},"description":"Latitude. Must be given together with lng."},{"name":"lng","in":"query","required":false,"schema":{"type":"number","minimum":-180,"maximum":180},"description":"Longitude. Must be given together with lat."},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100,"default":15},"description":"Search radius around lat/lng in km."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"description":"Offers per page (groups may be fewer)."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string","maxLength":512},"description":"Opaque keyset cursor from a previous page's next_cursor."}],"responses":{"200":{"description":"Groups of offers matching the query.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["groups","next_cursor","total_offers"],"additionalProperties":false,"properties":{"groups":{"type":"array","items":{"$ref":"#/components/schemas/SearchGroup"}},"next_cursor":{"type":["string","null"]},"total_offers":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/offers/history":{"get":{"operationId":"getOfferHistory","summary":"Price series for one item key across chains and weeks","description":"Every offer sharing one persisted item key (as carried by a search group) whose validity touches the last `days` days, oldest first, with lowest/median context. `summary` is computed from the CAMPAIGN rows only — these are ADVERTISED leaflet prices, never a normal or shelf price. The series also carries `regular` rows (everyday shelf prices, Salling only); use each point's price_kind to tell them apart. `summary` is null when the key has no campaign offer in the window, even if it has shelf prices; that is not an error.","tags":["Offers"],"parameters":[{"name":"item_key","in":"query","required":true,"schema":{"type":"string","minLength":1,"maxLength":200},"description":"The item_key of a group from GET /offers/search."},{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":90,"default":30},"description":"Look-back window in days (1–90, default 30)."}],"responses":{"200":{"description":"The item key's price series and summary.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["item_key","product_id","title","days","series","summary"],"additionalProperties":false,"properties":{"item_key":{"type":"string"},"product_id":{"type":["string","null"],"format":"uuid"},"title":{"type":["string","null"],"description":"Normalised display title, null when the series is empty."},"days":{"type":"integer"},"series":{"type":"array","items":{"$ref":"#/components/schemas/OfferHistoryPoint"}},"summary":{"oneOf":[{"$ref":"#/components/schemas/OfferHistorySummary"},{"type":"null"}]}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/offers/price-drops":{"get":{"operationId":"listPriceDrops","summary":"This week's biggest price drops","description":"Active campaign offers whose price fell against the same item (its persisted item key, which must include a pack size) in the same chain's previous catalogue — the newest campaign offer whose validity ended within the 21 days before this offer's validFrom. Only offers that started within the last 7 days count, one row per item and chain, largest absolute drop first. Campaign offers are chain-level, so lat+lng narrows to chains with an active store within radius_km. Facts only: format your own copy from previousPrice, price, dropAmount and dropPercent.","tags":["Offers"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":20,"default":5},"description":"How many drops to return."},{"name":"chain_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Restrict to a single chain by slug."},{"name":"lat","in":"query","required":false,"schema":{"type":"number","minimum":-90,"maximum":90},"description":"Latitude. Must be given together with lng."},{"name":"lng","in":"query","required":false,"schema":{"type":"number","minimum":-180,"maximum":180},"description":"Longitude. Must be given together with lat."},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100,"default":15},"description":"Only chains with an active store within this radius of lat/lng."}],"responses":{"200":{"description":"This week's biggest drops, largest first. An empty list is not an error.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PriceDrop"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/ChainNotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/offers/term-history":{"get":{"operationId":"getOfferTermHistory","summary":"Weekly price history for everything matching a search term","description":"Weekly price history for everything matching a search TERM (the same matcher as GET /offers/search), across ALL chains, INCLUDING expired offers — `catalog_offers` is never purged, so this is the term's whole observed history, not just what's live today. One entry per ISO week (Monday start, Europe/Copenhagen) in the window, oldest first; a week with no matching offer still appears, with nulls/0 rather than being omitted. `summary.typical_price` is the median of the weekly `min_price` over weeks that had an offer; `summary.weeks_on_offer_last_8` counts, of the last 8 weeks, how many had at least one CAMPAIGN offer (a `regular` shelf price does not count as \"on offer\"). With `chain_slug` the series covers only that chain's own offers (its own typical price, cheapest week and weeks on offer).","tags":["Offers"],"parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2,"maxLength":64},"description":"Search text, e.g. \"pepsi max\"."},{"name":"weeks","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":26,"default":26},"description":"Look-back window in ISO weeks (1–26, default 26)."},{"name":"chain_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Restrict the series to a single chain's own offers (SA-0223); omit for all chains. Unknown slug: 404 `chain_not_found`."}],"responses":{"200":{"description":"The term's weekly price history and summary.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"object","required":["query","weeks","summary"],"additionalProperties":false,"properties":{"query":{"type":"string"},"weeks":{"type":"array","items":{"$ref":"#/components/schemas/TermHistoryWeek"}},"summary":{"$ref":"#/components/schemas/TermHistorySummary"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/ChainNotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/price-history":{"get":{"operationId":"getPriceHistory","summary":"Daily minimum price series for a set of products","description":"Series are built from offers and observations linked to each curated product. The catalog is expanding — an empty data array for a product means nothing is linked to it yet; it is not an error.","tags":["Price history"],"parameters":[{"name":"product_ids","in":"query","required":true,"schema":{"type":"string"},"description":"Comma-separated list of UUIDs (1–50). Products to fetch history for."},{"name":"days","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":90,"default":30},"description":"Look-back window in days (1–90, default 30)."}],"responses":{"200":{"description":"Per-day minimum price points.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/PriceHistoryPoint"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/stores":{"get":{"operationId":"listStores","summary":"List stores, or find the nearest to a coordinate","description":"Provide lat+lng (optionally radius_km) for nearest-first ordering, or chain_slug to list a chain. Not cursor-paginated: geo mode is distance-ordered and list mode caps at limit=500 (covers the full store set today) — pagination.cursor is always null here and does not mean \"last page reached\" the way it does on /products and /offers.","tags":["Stores"],"parameters":[{"name":"chain_slug","in":"query","required":false,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":64},"description":"Restrict to a single chain by slug."},{"name":"postal_code","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d{1,4}$"},"description":"Danish postcode prefix (1–4 digits); `21` matches all of 21xx."},{"name":"lat","in":"query","required":false,"schema":{"type":"number","minimum":-90,"maximum":90},"description":"Latitude; must be paired with lng."},{"name":"lng","in":"query","required":false,"schema":{"type":"number","minimum":-180,"maximum":180},"description":"Longitude; must be paired with lat."},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","exclusiveMinimum":0,"maximum":500},"description":"Optional radius for nearest search; requires lat/lng."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":50},"description":"Page size (1–500, default 50)."}],"responses":{"200":{"description":"Stores (nearest-first when a coordinate is given).","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Store"}},"pagination":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":"integer","description":"The effective page size."},"cursor":{"type":["string","null"],"description":"Opaque cursor for the next page; null on the last page."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/stores/{id}":{"get":{"operationId":"getStore","summary":"Get a store by id","tags":["Stores"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The store.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Store"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/stores/{id}/stock":{"get":{"operationId":"getStoreStock","summary":"Community shelf-stock signal for one store","description":"The latest approved shelf observation per curated product at this store: status, when it was observed, its validity window, and (C4) how many independent reporters confirmed the status within the last 24 hours. (SA-0022) `confidence` says how much to trust that status: `confirmed` (a partner report, or 2+ agreeing sources at combined weight >= 1.0) or `reported` (a single established source, or 2+ lower-trust agreeing sources); `trusted: false` flags a status backed only by new/unestablished sources (e.g. a first-ever report). Empty `products` means no reports yet, not an error.","tags":["Stores"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The store's current stock signal.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/StoreStock"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/stores/{id}/local-prices":{"get":{"operationId":"getStoreLocalPrices","summary":"Local (crowd-observed) shelf prices for one store (SA-0162)","description":"The latest APPROVED observation per product at this store that carries a price — kept data-separate from CatalogOffer: this is never a discount/leaflet claim, only what a person saw on the shelf. A price-less observation never appears here (see `/stores/{id}/stock`). `is_price_outlier` rows are excluded. Open to any catalog key — no coverage-package grant required, unlike `/observed-prices`; tier gating on freshness is a pricing decision not yet built. RAW facts only: `observedAt` carries no server-computed age (the response is CDN-cached).","tags":["Stores"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The store's local observed prices.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/StoreLocalPrices"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/local-prices":{"get":{"operationId":"listNearbyLocalPrices","summary":"Local (crowd-observed) shelf prices near a point (SA-0164)","description":"The radius-wide sibling of `/stores/{id}/local-prices`: the latest APPROVED, non-outlier observation per (store, product) that carries a price, at any active store within radius_km of lat/lng — newest first. Same data-separation as the per-store route: never a CatalogOffer, never mixed with discount prices. `summary` gives the same-radius proof-line counts: `today` (since local Copenhagen midnight) prices/stores, `week` (rolling 7 days) prices/contributors — contributors is a COUNT only, never a reporter id. Open to any catalog key — no coverage-package grant required. RAW facts only: `observedAt` carries no server-computed age (the response is CDN-cached).","tags":["Stores"],"parameters":[{"name":"lat","in":"query","required":true,"schema":{"type":"number","minimum":-90,"maximum":90},"description":"Latitude."},{"name":"lng","in":"query","required":true,"schema":{"type":"number","minimum":-180,"maximum":180},"description":"Longitude."},{"name":"radius_km","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100,"default":15},"description":"Search radius in km (1–100, default 15)."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Page size (1–100, default 20)."}],"responses":{"200":{"description":"Nearby local observed prices plus the radius summary.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/NearbyLocalPrices"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/shelf-label/read":{"post":{"operationId":"readShelfLabel","summary":"Read a shelf label's price/name/EAN from a photo (SA-0165)","description":"One vision call: uploads the given photo and reads the shelf-edge label's name, price, EAN and stock hint, then returns catalog products matching what was read (the EAN if legible, otherwise a name search) as `candidates`. `image_key` is the uploaded photo's Blob pathname — not yet accepted by `POST /api/v1/observations` (which has no image field), kept for a future observation-with-photo write. An unreadable label is still a 200: `reading.reason` is `unreadable` and `candidates` is empty — it consumed the vision call same as a readable one, it just has nothing to offer. Only an infra failure (Blob or the vision deployment unavailable) is a 503.","tags":["Observations"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShelfLabelReadRequest"}}}},"responses":{"200":{"description":"The reading, any matching catalog candidates, and the uploaded photo key.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/ShelfLabelReadResult"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"413":{"$ref":"#/components/responses/PayloadTooLarge"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/chains":{"get":{"operationId":"listChains","summary":"List all chains","tags":["Chains"],"parameters":[],"responses":{"200":{"description":"All chains (not paginated).","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Chain"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/stats":{"get":{"operationId":"getStats","summary":"Platform-wide catalog aggregates (public marketing numbers)","description":"The same real numbers rendered on the ShelfAtlas homepage — live offer count, coverage, per-chain breakdown. Unauthenticated: the data is already public, so a key would protect nothing. Bounded instead by a per-IP rate limit and a CDN cache (`Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400`).","tags":["Stats"],"security":[],"parameters":[],"responses":{"200":{"description":"Current platform stats snapshot.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"$ref":"#/components/schemas/Stats"}}}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/v1/public/catalog/observed-prices":{"get":{"operationId":"listObservedPrices","summary":"Latest observed shelf prices inside your coverage packages (SA-0158)","description":"The B2B read behind Shelf Intelligence: the latest APPROVED shelf observation per product per store, limited to the coverage packages this API key has been granted (a package = a customer's stores × products, kept observed by the field team). A key without an active package gets 403 `no_coverage_package`. Only human observations — never catalog prices — and no reporter identity: `trust` is the source class (`partner`, `established`, `new`). Private, never cached.","tags":["ObservedPrices"],"parameters":[{"name":"ean","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d{8}$|^\\d{13}$"},"description":"Only this product (EAN-8/13)."},{"name":"store_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Only this store."},{"name":"since","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Only observations at or after this instant."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":500,"default":200},"description":"Page size (1–500, default 200)."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip."}],"responses":{"200":{"description":"Latest observed price per (product, store) the key may see.","content":{"application/json":{"schema":{"type":"object","required":["ok","data","limit","offset"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/ObservedPrice"}},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The key has no active coverage package (`no_coverage_package`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"$ref":"#/components/responses/ServiceUnavailable"}}}},"/api/v1/public/catalog/seo-terms":{"get":{"operationId":"listSeoTerms","summary":"Generated SEO term list (SA-0137 B1)","description":"The daily-refreshed list of SEO terms derived from currently-active campaign offers, backing Sparekurven's `/tilbud/<term>` pages. Unauthenticated like `/stats`: bounded by a per-IP rate limit and a CDN cache (`Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400`).","tags":["SeoTerms"],"security":[],"parameters":[{"name":"min_offers","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":3},"description":"Only terms with at least this many offers in the last refresh."},{"name":"min_chains","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":2},"description":"Only terms carried by at least this many distinct chains."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":1000,"default":1000},"description":"Page size (1–1000, default 1000)."}],"responses":{"200":{"description":"Terms meeting the thresholds, ordered by offers_7d desc.","content":{"application/json":{"schema":{"type":"object","required":["ok","data"],"additionalProperties":false,"properties":{"ok":{"type":"boolean","enum":[true]},"data":{"type":"array","items":{"$ref":"#/components/schemas/SeoTerm"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/v1/observations":{"post":{"operationId":"createObservation","summary":"Submit a shelf observation","description":"Records a crowd-sourced shelf signal — stock status, and optionally a price — for a product or offer at a store. Any API key (free or partner tier) may call this with the usual Bearer header; a caller without a key may submit anonymously with an X-Device-Token header instead (a client-generated UUID identifying the device — any UUID version is accepted, the check is purely the 8-4-4-4-12 shape). Sending both headers uses the API key. Contributing observations does not consume or grant API quota; this endpoint is rate-limited separately from the read endpoints (10 req/min per IP for anonymous and device-token callers, 60 req/min per API key for sa_live_ callers — SA-0037 —, plus 50 req/15min per IP and per device token for anonymous callers).","tags":["Observations"],"security":[{"bearerAuth":[]},{"deviceTokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationRequest"}}}},"responses":{"201":{"description":"Observation recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationResult"}}}},"400":{"description":"Malformed body — a required field is missing/wrong type, neither `ean` nor `offer_id` was given, or both `reporter_clerk_id` and `device_token` were given together (`invalid_body`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationError"}}}},"401":{"description":"Neither an `Authorization` header nor an `X-Device-Token` header was sent (`missing_auth`); the API key is malformed/unrecognised or the device token is not a valid UUID (`invalid_auth`); or the API key was revoked (`revoked`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationError"}}}},"422":{"description":"The referenced entity does not exist: no curated product matches `ean` (`unknown_ean`), no CatalogOffer matches `offer_id` (`unknown_offer`), or no store matches `store_id` (`unknown_store`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationError"}}}},"429":{"description":"Two independent limiters can produce this: the shared per-IP write-route limit in apps/web/proxy.ts (10 req/min per IP for anonymous callers, 60 req/min per API key for sa_live_ callers, checked before this handler runs) and the handler's own anonymous-caller check (50 req/15min, checked once per IP and once per device token, X-Device-Token callers only). Both return `rate_limit_exceeded` in the same { error, code, retryAfterSec } shape.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationError"}}}}}}},"/api/v1/steering-events":{"post":{"operationId":"recordSteeringEvent","summary":"Record a steering render (partner-tier keys only)","description":"First-party telemetry: a partner's consumer surface posts one row per candidate chain every time it localizes a \"best choice\" card, so the chosen store can be compared against the nearest store per candidate chain. Rolled up into daily, per-chain aggregates only — no per-request identifiers or coordinates are ever stored; `distance_band` is a client-computed bucket ('<1'|'1-3'|'3-10'|'>10'), never a raw lat/lng. Partner-tier API keys only (ShelfAtlas' own consumer surfaces); any other tier gets `forbidden_tier`. Chain ids are resolved from the given store ids server-side — never accepted from the client.","tags":["Observations"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SteeringEventRequest"}}}},"responses":{"202":{"description":"Steering rows recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SteeringEventResult"}}}},"400":{"description":"Malformed body (`invalid_body`) — including a coordinate-like key (`lat`/`lng`/`lon`/`latitude`/`longitude`) anywhere in the payload, `involved` empty or over 25 entries, or an unrecognised field — or a referenced entity does not exist: no product matches `product_id` (`unknown_product`), or a store id in `winning_store_id`/`involved[].nearest_store_id` does not match any store (`unknown_store`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SteeringEventError"}}}},"401":{"description":"No `Authorization` header (`missing_auth`); the API key is malformed/unrecognised (`invalid_auth`); or the key was revoked (`revoked`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SteeringEventError"}}}},"403":{"description":"The API key is not partner-tier (`forbidden_tier`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SteeringEventError"}}}}}}},"/api/v1/mcp":{"post":{"operationId":"mcpJsonRpc","summary":"MCP server (JSON-RPC 2.0) — the catalog as tools for AI assistants","description":"Model Context Protocol endpoint (stateless JSON transport). Send a JSON-RPC 2.0 request such as `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}` with `Accept: application/json, text/event-stream`. Same API key and monthly quota as the REST reads.","tags":["MCP"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"responses":{"200":{"description":"JSON-RPC 2.0 response.","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A ShelfAtlas API key: `Authorization: Bearer sa_live_<key>`."},"deviceTokenAuth":{"type":"apiKey","in":"header","name":"X-Device-Token","description":"Anonymous per-device identity for POST /api/v1/observations: a UUID the client generates and keeps locally. Any UUID version is accepted — the check is purely the 8-4-4-4-12 hex-digit shape."}},"schemas":{"Product":{"type":"object","additionalProperties":false,"required":["id"],"properties":{"id":{"type":"string","format":"uuid"},"canonicalName":{"type":["string","null"]},"variant":{"type":["string","null"]},"brandId":{"type":["string","null"],"format":"uuid"},"primaryCategoryId":{"type":["string","null"],"format":"uuid"},"imageUrl":{"type":["string","null"]},"ean":{"type":["string","null"],"description":"EAN/GTIN barcode, when known. Currently always null on public API responses — every EAN we hold today comes from a source that is not part of the public catalog API (see priceKind's description), and no other chain's feed carries a barcode to attach either. Never treat this as a stable identifier: `id` is the one guaranteed-present, guaranteed-stable key for a product, and GET /products/{id} also accepts a bare EAN/GTIN as a convenience lookup when you do have one from your own data."},"unitCount":{"type":"integer"},"volumeMl":{"type":["integer","null"]},"isActive":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"packageType":{"type":["string","null"],"description":"'can' | 'bottle' | 'carton' | 'pouch' | 'box' | 'jar' | 'other'"},"packageCount":{"type":["integer","null"]},"packageSizeValue":{"type":["string","null"],"description":"Decimal string (Postgres numeric)."},"packageSizeUnit":{"type":["string","null"],"description":"'ml' | 'cl' | 'dl' | 'l' | 'g' | 'kg' | 'stk'"}}},"Offer":{"type":"object","additionalProperties":false,"required":["id"],"properties":{"id":{"type":"string","format":"uuid"},"chainId":{"type":["string","null"],"format":"uuid"},"storeId":{"type":["string","null"],"format":"uuid"},"productId":{"type":["string","null"],"format":"uuid"},"rawName":{"type":"string"},"price":{"type":"string","description":"Decimal string (Postgres numeric)."},"originalPrice":{"type":["string","null"]},"unitPrice":{"type":["string","null"]},"unitPriceUnit":{"type":["string","null"],"enum":["l","kg","stk",null],"description":"Unit of `unitPrice`: per litre, per kilo or per piece. Null when unknown."},"currency":{"type":"string","minLength":3,"maxLength":3},"validFrom":{"type":"string","format":"date-time"},"validTo":{"type":"string","format":"date-time"},"ingestedAt":{"type":"string","format":"date-time","description":"When this offer was last actually observed by ingest — the freshness signal for \"how current is this price\"."},"imageUrl":{"type":["string","null"]},"volumeMl":{"type":["integer","null"]},"unitCount":{"type":"integer"},"priceKind":{"type":"string","enum":["campaign","regular"],"description":"What kind of price this is: 'campaign' — a published offer (leaflet or a chain month-campaign); 'regular' — the everyday shelf price observed in-store. Public API responses today only ever contain 'campaign' rows — everyday shelf prices are not part of the public catalog API. (Both kinds are real, currently-valid prices where they do appear; this field labels them, it doesn't filter within a response.)"},"cropUrl":{"type":["string","null"],"description":"Our own verified crop of this offer's leaflet tile — an image of exactly this offer. Present only on chain-scoped lists (chain_slug), null where the chain's crops are not published."},"matchedProductIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Which of the query's products this offer matched. Present only on product-scoped offer queries (product_id / product_ids / /products/:id/offers). Use this — not the legacy nullable productId column — to attribute offers to products."},"previousPrice":{"type":["string","null"],"description":"SA-0186: what the same item cost in the same chain in its previous catalogue (the newest campaign offer with the same item key whose validity ended within the 21 days before this offer's validFrom). Decimal string like `price`; null when unknown — no item key, a key without a pack size, a regular shelf price, or no such offer. Present on GET /offers and /offers/price-drops."},"isNewThisWeek":{"type":"boolean","description":"SA-0186: true when this campaign offer started within the last 7 days and the chain had no offer for the same item (any price kind) in the 21 days before it. False when unknown. Present on GET /offers and /offers/price-drops."}}},"PriceDrop":{"type":"object","additionalProperties":false,"required":["id","chainId","price","validFrom","validTo","previousPrice","isNewThisWeek","dropAmount","dropPercent"],"properties":{"id":{"type":"string","format":"uuid"},"chainId":{"type":["string","null"],"format":"uuid"},"storeId":{"type":["string","null"],"format":"uuid"},"productId":{"type":["string","null"],"format":"uuid"},"rawName":{"type":"string"},"price":{"type":"string","description":"Decimal string (Postgres numeric)."},"originalPrice":{"type":["string","null"]},"unitPrice":{"type":["string","null"]},"unitPriceUnit":{"type":["string","null"],"enum":["l","kg","stk",null],"description":"Unit of `unitPrice`: per litre, per kilo or per piece. Null when unknown."},"currency":{"type":"string","minLength":3,"maxLength":3},"validFrom":{"type":"string","format":"date-time"},"validTo":{"type":"string","format":"date-time"},"ingestedAt":{"type":"string","format":"date-time","description":"When this offer was last actually observed by ingest — the freshness signal for \"how current is this price\"."},"imageUrl":{"type":["string","null"]},"volumeMl":{"type":["integer","null"]},"unitCount":{"type":"integer"},"priceKind":{"type":"string","enum":["campaign","regular"],"description":"What kind of price this is: 'campaign' — a published offer (leaflet or a chain month-campaign); 'regular' — the everyday shelf price observed in-store. Public API responses today only ever contain 'campaign' rows — everyday shelf prices are not part of the public catalog API. (Both kinds are real, currently-valid prices where they do appear; this field labels them, it doesn't filter within a response.)"},"cropUrl":{"type":["string","null"],"description":"Our own verified crop of this offer's leaflet tile — an image of exactly this offer. Present only on chain-scoped lists (chain_slug), null where the chain's crops are not published."},"matchedProductIds":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Which of the query's products this offer matched. Present only on product-scoped offer queries (product_id / product_ids / /products/:id/offers). Use this — not the legacy nullable productId column — to attribute offers to products."},"previousPrice":{"type":["string","null"],"description":"SA-0186: what the same item cost in the same chain in its previous catalogue (the newest campaign offer with the same item key whose validity ended within the 21 days before this offer's validFrom). Decimal string like `price`; null when unknown — no item key, a key without a pack size, a regular shelf price, or no such offer. Present on GET /offers and /offers/price-drops."},"isNewThisWeek":{"type":"boolean","description":"SA-0186: true when this campaign offer started within the last 7 days and the chain had no offer for the same item (any price kind) in the 21 days before it. False when unknown. Present on GET /offers and /offers/price-drops."},"dropAmount":{"type":"string","description":"previousPrice − price, decimal string (DKK), always > 0."},"dropPercent":{"type":"number","description":"dropAmount as a percent of previousPrice, one decimal."}}},"Store":{"type":"object","additionalProperties":false,"required":["id","name","chainSlug","chainName","address","city","lat","lng","timezone"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"chainSlug":{"type":"string"},"chainName":{"type":"string"},"address":{"type":"string"},"city":{"type":"string"},"postalCode":{"type":["string","null"]},"lat":{"type":"number"},"lng":{"type":"number"},"openingHours":{"type":["object","null"],"description":"Weekly opening hours keyed mon..sun; each an array of {open,close} \"HH:MM\" intervals (empty = closed).","additionalProperties":{"type":"array","items":{"type":"object","required":["open","close"],"properties":{"open":{"type":"string"},"close":{"type":"string"}}}}},"timezone":{"type":"string"},"topReporterAlias":{"type":["string","null"],"description":"\"Claim to fame\" (2026-08-20): the store's top approved-alias reporter over the rolling 30-day window (min 5 approved observations), or null when there is no eligible leader or the leader has no admin-approved alias."},"topReporterStandInNumber":{"type":["number","null"],"description":"Pending stand-in name (2026-08-21): a deterministic number (1..9999) for the qualifying leader when their alias is awaiting approval or re-pick (pending/rejected), so a chosen-but-unapproved name never appears publicly. Never set at the same time as topReporterAlias, and null when there is no eligible leader or the leader has no alias row at all."}}},"StoreStock":{"type":"object","additionalProperties":false,"required":["storeId","products"],"properties":{"storeId":{"type":"string","format":"uuid"},"products":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["ean","productId","stockStatus","observedAt","validUntil","confirmations"],"properties":{"ean":{"type":"string"},"productId":{"type":["string","null"],"format":"uuid"},"stockStatus":{"type":"string","enum":["in_stock","low_stock","out_of_stock"]},"observedAt":{"type":"string","format":"date-time"},"validUntil":{"type":["string","null"],"format":"date-time","description":"When the signal stops applying; a consumer must treat an expired row as \"not reported\"."},"confirmations":{"type":"integer","minimum":1,"description":"Independent reporters behind the status within the last 24h (C4 trust layer) — the same count `confidence` was derived from."},"confidence":{"type":"string","enum":["confirmed","reported"],"description":"How much this status is trusted (SA-0022): confirmed = a partner report, or 2+ agreeing sources reaching combined weight 1.0; reported = a single established source (5+ approved reports) or 2+ agreeing lower-trust sources. A lone new/low-trust report is surfaced as reported with trusted=false (SA-0275)."},"trusted":{"type":"boolean","description":"SA-0275: false when only new/unestablished sources (no partner, none with 5+ approved reports) back the status — e.g. one first-ever report; render it as \"one report\". Optional for pre-SA-0275 servers."}}}}}},"StoreLocalPrices":{"type":"object","additionalProperties":false,"required":["storeId","store","prices"],"properties":{"storeId":{"type":"string","format":"uuid"},"store":{"type":"object","additionalProperties":false,"required":["id","name","chainSlug"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"chainSlug":{"type":"string"}}},"prices":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["ean","label","priceCents","stockStatus","observedAt","trust"],"properties":{"ean":{"type":"string"},"label":{"type":["string","null"],"description":"Curated product name, or null when the ean is not a known product."},"priceCents":{"type":"integer","description":"Shelf price in øre (DKK×100) as observed."},"stockStatus":{"type":["string","null"],"enum":["in_stock","low_stock","out_of_stock",null]},"observedAt":{"type":"string","format":"date-time","description":"When the shelf was seen — a raw fact, no server-computed age."},"trust":{"type":"string","enum":["partner","established","new"],"description":"Source class — never the reporter identity."}}}}}},"NearbyLocalPrices":{"type":"object","additionalProperties":false,"required":["items","summary"],"properties":{"items":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["ean","label","priceCents","stockStatus","observedAt","trust","store","distanceKm"],"properties":{"ean":{"type":"string"},"label":{"type":["string","null"],"description":"Curated product name, or null when the ean is not a known product."},"priceCents":{"type":"integer","description":"Shelf price in øre (DKK×100) as observed."},"stockStatus":{"type":["string","null"],"enum":["in_stock","low_stock","out_of_stock",null]},"observedAt":{"type":"string","format":"date-time","description":"When the shelf was seen — a raw fact, no server-computed age."},"trust":{"type":"string","enum":["partner","established","new"],"description":"Source class — never the reporter identity."},"store":{"type":"object","additionalProperties":false,"required":["id","name","chainSlug","chainName"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"chainSlug":{"type":"string"},"chainName":{"type":"string"}}},"distanceKm":{"type":"number","description":"Great-circle distance from lat/lng, rounded to 0.1 km."}}}},"summary":{"type":"object","additionalProperties":false,"required":["today","week"],"description":"Same-radius proof-line counts (design 2026-09-23 §1, §2 layer 5).","properties":{"today":{"type":"object","additionalProperties":false,"required":["prices","stores"],"description":"Since local Copenhagen midnight.","properties":{"prices":{"type":"integer"},"stores":{"type":"integer","description":"Distinct stores with at least one price seen today."}}},"week":{"type":"object","additionalProperties":false,"required":["prices","contributors"],"description":"Rolling last 7 days.","properties":{"prices":{"type":"integer"},"contributors":{"type":"integer","description":"Distinct contributors (count only — never a reporter id)."}}}}}}},"Chain":{"type":"object","additionalProperties":false,"required":["id","slug","name","country"],"properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"displayName":{"type":["string","null"]},"country":{"type":"string","minLength":2,"maxLength":2},"logoUrl":{"type":["string","null"]}}},"SearchGroup":{"type":"object","additionalProperties":false,"required":["key","title","offers"],"properties":{"key":{"type":"string","description":"Opaque group identity: `p:<product_id>` when the group resolved to a curated product, else `k:<item_key>`. Compare it, never parse it."},"title":{"type":"string","description":"Normalised display title of the group."},"item_key":{"type":["string","null"],"description":"Persisted item identity of the group's representative offer. Pass this to GET /offers/history."},"product_id":{"type":["string","null"],"format":"uuid","description":"Curated product behind the group, when known."},"offers":{"type":"array","items":{"$ref":"#/components/schemas/SearchOffer"}}}},"SearchOffer":{"type":"object","additionalProperties":false,"required":["offer_id","chain_slug","chain_name","title","price_cents","price_kind","valid_from","valid_to","score"],"properties":{"offer_id":{"type":"string","format":"uuid"},"chain_slug":{"type":"string"},"chain_name":{"type":"string"},"title":{"type":"string","description":"The offer's raw leaflet title, not the group title."},"price_cents":{"type":"integer","description":"Advertised price in øre."},"normal_price_cents":{"type":["integer","null"]},"price_kind":{"type":"string","enum":["campaign","regular"],"description":"'regular' is an everyday shelf price, not an offer."},"unit_price_cents":{"type":["integer","null"]},"unit":{"type":["string","null"],"enum":["l","kg","stk",null]},"valid_from":{"type":"string","format":"date","description":"Europe/Copenhagen calendar date."},"valid_to":{"type":"string","format":"date","description":"Inclusive last day, Europe/Copenhagen."},"nearest_store":{"oneOf":[{"$ref":"#/components/schemas/SearchNearestStore"},{"type":"null"}],"description":"Only present when lat+lng were given."},"stock":{"oneOf":[{"$ref":"#/components/schemas/SearchStock"},{"type":"null"}],"description":"Trust-weighted shelf status at the nearest store."},"score":{"type":"number","description":"Worth-the-trip score used for ordering."},"image_url":{"type":["string","null"],"description":"Packshot of the matched product, inherited across the item key when the offer has no match of its own."},"crop_url":{"type":["string","null"],"description":"Our own verified crop of the offer's leaflet tile, for chains where leaflet crops are switched on; null otherwise. Falls between packshot and category illustration in the consumer image layering."},"page_box":{"oneOf":[{"type":"object","required":["x1","y1","x2","y2"],"properties":{"x1":{"type":"number"},"y1":{"type":"number"},"x2":{"type":"number"},"y2":{"type":"number"}}},{"type":"null"}],"description":"SA-0282: the cell the clipper chose for this offer, in page-width units (x 0..1, y 0..3 — y is measured in page WIDTHS), present only when crop_url is the whole-page picture so a client can zoom the page onto the offer's field; null otherwise."},"previous_price_cents":{"type":["integer","null"],"description":"SA-0186: the same item's price in the same chain's previous catalogue, in øre (see Offer.previousPrice); null when unknown."},"is_new_this_week":{"type":"boolean","description":"SA-0186: started within the last 7 days and not offered by the chain in the 21 days before (see Offer.isNewThisWeek)."}}},"SearchNearestStore":{"type":"object","additionalProperties":false,"required":["store_id","name","city","lat","lng","distance_km"],"properties":{"store_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"city":{"type":["string","null"]},"lat":{"type":"number"},"lng":{"type":"number"},"distance_km":{"type":"number"},"country":{"type":["string","null"],"description":"ISO country code of the store (e.g. DK, DE) — SA-0077 grænsehandel marking. Not required: absent on a response predating this field."}}},"SearchStock":{"type":"object","additionalProperties":false,"required":["stock_status","observed_at","confidence","sources"],"properties":{"stock_status":{"type":"string","enum":["in_stock","low_stock","out_of_stock"]},"observed_at":{"type":"string","format":"date-time"},"confidence":{"type":"string"},"sources":{"type":"integer","description":"Number of distinct sources behind the status."},"trusted":{"type":"boolean","description":"SA-0275: false when only new/unestablished sources back the status (e.g. one first-ever report). Optional for pre-SA-0275 servers."}}},"OfferHistoryPoint":{"type":"object","additionalProperties":false,"required":["chain_slug","chain_name","valid_from","valid_to","price_cents","price_kind"],"properties":{"chain_slug":{"type":"string"},"chain_name":{"type":"string"},"valid_from":{"type":"string","format":"date"},"valid_to":{"type":"string","format":"date","description":"Inclusive last day."},"price_cents":{"type":"integer","description":"Advertised price in øre."},"price_kind":{"type":"string","enum":["campaign","regular"]}}},"OfferHistorySummary":{"type":"object","additionalProperties":false,"required":["lowest_cents","median_cents","chains","weeks_seen"],"description":"Context derived from the CAMPAIGN rows of the series. These are advertised leaflet prices — never present them as a normal or shelf price.","properties":{"lowest_cents":{"type":"integer"},"median_cents":{"type":"integer"},"chains":{"type":"integer","description":"Distinct chains that advertised the item."},"weeks_seen":{"type":"integer","description":"Distinct ISO weeks in which the item was advertised."}}},"TermHistoryWeek":{"type":"object","additionalProperties":false,"required":["week_start","min_price","min_unit_price","unit_price_unit","offer_count","campaign_count","chain_count","cheapest_chain_slug","cheapest_chain_name","cheapest_unit_price","cheapest_unit_price_chain_slug","cheapest_unit_price_chain_name"],"description":"One ISO week (Monday, Europe/Copenhagen) in the /offers/term-history window. A week with no matching offer still appears, with offer_count/campaign_count/chain_count 0 and every other field null.","properties":{"week_start":{"type":"string","format":"date","description":"The week's Monday, Europe/Copenhagen calendar."},"min_price":{"type":["number","null"],"description":"Lowest shelf price (DKK) across both price_kinds that week — across pack sizes (a 33 cl can and a 1.5 l bottle compete), so not a like-for-like \"cheapest\"; use cheapest_unit_price for that."},"min_unit_price":{"type":["number","null"],"description":"The cheapest offer's unit price (DKK), same row as min_price."},"unit_price_unit":{"type":["string","null"],"enum":["l","kg","stk",null]},"offer_count":{"type":"integer","description":"Distinct matching offers that week, both price kinds."},"campaign_count":{"type":"integer","description":"Of offer_count, how many were price_kind campaign."},"chain_count":{"type":"integer","description":"Distinct chains carrying a matching offer that week."},"cheapest_chain_slug":{"type":["string","null"],"description":"The min_price offer's chain."},"cheapest_chain_name":{"type":["string","null"]},"cheapest_unit_price":{"type":["number","null"],"description":"SA-0230: the week's cheapest unit price (DKK per summary.unit), comparing only offers in that unit (unit prices under 2 DKK per l/kg are treated as parse errors and skipped). Null when no offer that week carried the unit, or summary.unit is null."},"cheapest_unit_price_chain_slug":{"type":["string","null"],"description":"The chain of the cheapest_unit_price offer."},"cheapest_unit_price_chain_name":{"type":["string","null"]}}},"TermHistorySummary":{"type":"object","additionalProperties":false,"required":["weeks_total","weeks_with_offers","typical_price","lowest_price","lowest_price_week_start","lowest_price_chain_name","weeks_on_offer_last_8","unit","weeks_with_unit_price","typical_unit_price","lowest_unit_price","lowest_unit_price_week_start","lowest_unit_price_chain_name"],"properties":{"weeks_total":{"type":"integer","description":"Number of weeks in the requested window."},"weeks_with_offers":{"type":"integer","description":"Of weeks_total, how many had at least one matching offer."},"typical_price":{"type":["number","null"],"description":"Median of the weekly min_price over weeks with offers, 2 decimals. Shelf prices across pack sizes — prefer typical_unit_price for a \"typical price\" claim."},"lowest_price":{"type":["number","null"],"description":"The smallest weekly min_price in the window."},"lowest_price_week_start":{"type":["string","null"],"format":"date"},"lowest_price_chain_name":{"type":["string","null"]},"weeks_on_offer_last_8":{"type":"integer","description":"Of the LAST 8 weeks, how many had at least one campaign offer."},"unit":{"type":["string","null"],"enum":["l","kg","stk",null],"description":"SA-0230: the unit the window compares on — the one most offers carry (ties l > kg > stk). Null when no offer has a usable unit price; every *_unit_price field is then null."},"weeks_with_unit_price":{"type":"integer","description":"Weeks with a cheapest_unit_price."},"typical_unit_price":{"type":["number","null"],"description":"Median of the weekly cheapest_unit_price (DKK per unit), 2 decimals. The like-for-like \"typical price\"."},"lowest_unit_price":{"type":["number","null"],"description":"The smallest weekly cheapest_unit_price (DKK per unit)."},"lowest_unit_price_week_start":{"type":["string","null"],"format":"date"},"lowest_unit_price_chain_name":{"type":["string","null"]}}},"PriceHistoryPoint":{"type":"object","additionalProperties":false,"required":["productId","date","minPriceCents","chainSlug"],"properties":{"productId":{"type":"string","format":"uuid"},"date":{"type":"string","format":"date","description":"UTC day, YYYY-MM-DD."},"minPriceCents":{"type":"integer","description":"Minimum price that day, in øre."},"chainSlug":{"type":"string"}}},"ChainStat":{"type":"object","additionalProperties":false,"required":["name","slug","logoUrl","storeCount","liveOfferCount"],"properties":{"name":{"type":"string"},"slug":{"type":"string"},"logoUrl":{"type":["string","null"]},"storeCount":{"type":"integer"},"liveOfferCount":{"type":"integer"}}},"RecentOffer":{"type":"object","additionalProperties":false,"required":["rawName","price","currency","chainName","imageUrl"],"properties":{"rawName":{"type":"string"},"price":{"type":"string","description":"Decimal string (Postgres numeric)."},"currency":{"type":"string","minLength":3,"maxLength":3},"chainName":{"type":["string","null"]},"imageUrl":{"type":["string","null"]}}},"Stats":{"type":"object","additionalProperties":false,"required":["liveOffers","storesCount","chainsCovered","productsCount","priceHistoryDays","perChain","recentOffers","generatedAt"],"properties":{"liveOffers":{"type":"integer"},"storesCount":{"type":"integer"},"chainsCovered":{"type":"integer"},"productsCount":{"type":"integer"},"priceHistoryDays":{"type":"integer","description":"Age of the oldest price observation, in days."},"perChain":{"type":"array","items":{"$ref":"#/components/schemas/ChainStat"}},"recentOffers":{"type":"array","items":{"$ref":"#/components/schemas/RecentOffer"}},"latestOffersAcrossChains":{"type":"array","items":{"$ref":"#/components/schemas/RecentOffer"},"description":"Newest current campaign offers across chains (at most 2 per chain, 6 in total). Optional: absent in payloads cached before it existed."},"generatedAt":{"type":"string","format":"date-time","description":"Wallclock when this snapshot was assembled."}}},"ObservedPrice":{"type":"object","additionalProperties":false,"required":["ean","label","store","priceCents","stockStatus","observedAt","trust"],"properties":{"ean":{"type":"string","description":"EAN-8/13 as the package lists it."},"label":{"type":"string","description":"The package's human label for the product."},"store":{"type":"object","additionalProperties":false,"required":["id","name","chainSlug","chainName","lat","lng"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"chainSlug":{"type":"string"},"chainName":{"type":"string"},"lat":{"type":"number"},"lng":{"type":"number"}}},"priceCents":{"type":"integer","nullable":true,"description":"Shelf price in øre (DKK×100); null for a stock-only observation."},"stockStatus":{"type":"string","enum":["in_stock","low_stock","out_of_stock"]},"observedAt":{"type":"string","format":"date-time","description":"When the shelf was seen."},"trust":{"type":"string","enum":["partner","established","new"],"description":"Source class — a partner surface, a contributor with 5+ approved observations, or a newer contributor."}}},"SeoTerm":{"type":"object","additionalProperties":false,"required":["slug","term","query","offers_7d","chains_7d","chain_offers","last_seen"],"properties":{"slug":{"type":"string","description":"URL-safe identifier, e.g. `pepsi-max`."},"term":{"type":"string","description":"Display form, e.g. \"Pepsi Max\"."},"query":{"type":"string","description":"Lowercase search text to feed `offers.search({ q })`."},"offers_7d":{"type":"integer","description":"Active campaign offers behind this term at the last refresh."},"chains_7d":{"type":"integer","description":"Distinct chains carrying an offer behind this term."},"chain_offers":{"type":"array","nullable":true,"description":"Per-chain breakdown of the offers behind this term, ordered offers desc then chain_slug asc; the offers sum to `offers_7d` and the length is `chains_7d`. Campaign offers are chain-level, so a consumer sums the chains near a place to size a place × term page. null until the term's first refresh after this field was added.","items":{"type":"object","additionalProperties":false,"required":["chain_slug","offers"],"properties":{"chain_slug":{"type":"string","description":"Chain slug, as in `/chains` and `/stores/nearest`, e.g. `netto`."},"offers":{"type":"integer","description":"Active campaign offers from this chain behind the term."}}}},"last_seen":{"type":"string","format":"date-time","description":"When this term last met the thresholds."}}},"Error":{"type":"object","additionalProperties":true,"required":["error"],"properties":{"error":{"type":"string","description":"Machine-readable error code."},"message":{"type":"string"},"detail":{"type":"string"},"retry_after_sec":{"type":"integer","description":"Seconds to wait before retrying (429)."},"plan":{"type":"string","description":"Caller's resolved plan id (present on monthly_limit_reached)."},"limit":{"type":"integer","description":"The plan monthly request quota (present on monthly_limit_reached)."},"used":{"type":"integer","description":"Requests already spent this UTC month (present on monthly_limit_reached)."},"resets_at":{"type":"string","format":"date-time","description":"When the quota resets — next UTC month start (present on monthly_limit_reached)."},"next_plan":{"type":"object","nullable":true,"description":"The cheapest plan with a larger quota, or null at the top of the ladder.","properties":{"id":{"type":"string"},"name":{"type":"string"},"monthly_price_dkk":{"type":"integer"},"monthly_request_quota":{"type":"integer"}}},"plans_url":{"type":"string","format":"uri","description":"Public pricing page — no sign-in required."},"upgrade_url":{"type":"string","format":"uri"}}},"ObservationRequest":{"type":"object","additionalProperties":false,"required":["store_id","stock_status"],"description":"Exactly one of `ean` or `offer_id` is required — the other is omitted.","oneOf":[{"required":["ean"]},{"required":["offer_id"]}],"properties":{"ean":{"type":"string","minLength":1,"description":"EAN/GTIN of a curated product. Required if offer_id is omitted."},"offer_id":{"type":"string","format":"uuid","description":"A CatalogOffer id. Required if ean is omitted."},"store_id":{"type":"string","format":"uuid"},"stock_status":{"type":"string","enum":["in_stock","low_stock","out_of_stock"]},"price_cents":{"type":"integer","minimum":1,"description":"The observed price, in cents."},"currency":{"type":"string","minLength":3,"maxLength":3,"default":"DKK"},"consent_tier":{"type":"string","enum":["anonymous_aggregate","identifiable"],"default":"anonymous_aggregate"},"reporter_clerk_id":{"type":"string","minLength":1,"maxLength":255,"description":"Attributes the observation to an end-user your surface has already verified server-side. Honoured only for API-key auth."},"device_token":{"type":"string","format":"uuid","description":"Vouches for an anonymous end-user's device id instead of a Clerk id. Honoured only for API-key auth, and mutually exclusive with reporter_clerk_id."},"observed_at":{"type":"string","format":"date-time","description":"When the shelf was actually seen, for a report submitted after the fact (e.g. a next-day follow-up) instead of the request's own \"now\". Must not be more than 5 minutes in the future (clock-skew allowance) or more than 48 hours in the past; omit to stamp the current time, unchanged from before this field existed."}}},"ObservationResult":{"type":"object","additionalProperties":false,"required":["id","moderation_status"],"properties":{"id":{"type":"string","format":"uuid"},"moderation_status":{"type":"string","enum":["approved","pending_review"],"description":"A fresh observation auto-approves when it comes from a trusted source (a partner-tier API key, or a device with enough prior approved observations); otherwise it starts pending_review. 'rejected' is a later admin-moderation outcome — this endpoint never returns it on creation."},"top_reporter":{"type":"object","additionalProperties":true,"description":"Present only when the request resolves to an identified reporter (via reporter_clerk_id or the API key's own owner). Shape may grow; treat any field not documented on the docs page as optional."}}},"ObservationError":{"type":"object","additionalProperties":false,"required":["error","code"],"properties":{"error":{"type":"string","description":"Human-readable message; always equals `code`."},"code":{"type":"string","enum":["missing_auth","invalid_auth","revoked","invalid_body","unknown_ean","unknown_offer","unknown_store","rate_limit_exceeded"],"description":"Lower_snake_case for every case, including `rate_limit_exceeded` from the shared apps/web/proxy.ts write-route limiter (10 req/min per IP anonymous, 60 req/min per API key, every /api/v1 write route, checked before this handler ever runs) — it follows the same { error, code (same value) } convention as the handler's own errors (SA-0031)."},"retryAfterSec":{"type":"integer","description":"Seconds to wait before retrying — present only on a rate-limit response."}}},"SteeringEventRequest":{"type":"object","additionalProperties":false,"required":["product_id","winning_store_id","involved"],"properties":{"product_id":{"type":"string","format":"uuid"},"winning_store_id":{"type":"string","format":"uuid","description":"The store the \"best choice\" card actually pointed to."},"visitor_key":{"type":"string","format":"uuid","description":"Opaque per-device key the consumer surface generates and keeps locally, used only for the report's COUNT(DISTINCT visitor). Omit to skip unique-visitor counting for this render."},"involved":{"type":"array","minItems":1,"maxItems":25,"description":"One entry per candidate chain compared for this render.","items":{"type":"object","additionalProperties":false,"required":["nearest_store_id","distance_band","price_gap_cents"],"properties":{"nearest_store_id":{"type":"string","format":"uuid","description":"The candidate chain's store nearest the visitor."},"distance_band":{"type":"string","enum":["<1","1-3","3-10",">10"],"description":"Client-computed distance bucket, kilometres — never a raw coordinate."},"price_gap_cents":{"type":"integer","description":"This candidate's price minus the winning store's, in cents."}}}}}},"ShelfLabelReadRequest":{"type":"object","additionalProperties":false,"required":["image_base64","content_type"],"properties":{"image_base64":{"type":"string","description":"Base64-encoded image bytes (no `data:` URL prefix). Decoded size over 512 KB is a 413 — downscale client-side first."},"content_type":{"type":"string","enum":["image/jpeg","image/png","image/webp"]},"store_id":{"type":"string","format":"uuid","description":"Optional (SA-0179): the store the photo was taken in. Lets an item with no product be matched to that store's chain's current campaign offer (`matched_by: offer`)."}}},"ShelfLabelReading":{"type":"object","additionalProperties":false,"required":["name","price_cents","ean","confidence","reason"],"properties":{"name":{"type":["string","null"]},"price_cents":{"type":["integer","null"],"description":"DKK×100."},"ean":{"type":["string","null"]},"confidence":{"type":"number","description":"0.0–1.0 as judged by the vision model."},"reason":{"type":["string","null"],"enum":["unreadable",null],"description":"`unreadable` when the label was read but not usable; null on a good read."}}},"ShelfLabelCandidate":{"type":"object","additionalProperties":false,"required":["ean","name"],"properties":{"ean":{"type":"string"},"name":{"type":["string","null"]}}},"ShelfPhotoPrintedUnitPrice":{"type":"object","additionalProperties":false,"required":["value","per"],"properties":{"value":{"type":"number","description":"The label's own printed unit price, e.g. 10.73 for \"1 L = 10,73\"."},"per":{"type":"string","enum":["l","kg"]}}},"ShelfPhotoReadItem":{"type":"object","additionalProperties":false,"required":["name","brand","size_text","quantity_unit","volume_ml","unit_count","price_cents","spot","printed_unit_price","max_per_customer","ean","confidence","tier","known_product","candidates"],"description":"One item read from a shelf photo (SA-0179) — \"same action as reading a leaflet page\": ONE photo can return MANY items, each independently matched to the catalog.","properties":{"name":{"type":"string"},"brand":{"type":["string","null"]},"size_text":{"type":["string","null"]},"quantity_unit":{"type":["string","null"],"enum":["ml","g","pcs",null]},"volume_ml":{"type":["number","null"],"description":"ONE unit size, never the pack total."},"unit_count":{"type":["integer","null"]},"price_cents":{"type":["integer","null"],"description":"DKK×100. Null when no shelf-edge price label was legible for this item — never invented."},"spot":{"type":"boolean","description":"True when the shelf label is marked as a spot/campaign price."},"printed_unit_price":{"anyOf":[{"$ref":"#/components/schemas/ShelfPhotoPrintedUnitPrice"},{"type":"null"}]},"max_per_customer":{"type":["integer","null"],"description":"A printed purchase cap (\"Maks. N stk.\"), never a pack count."},"ean":{"type":["string","null"]},"confidence":{"type":"number","description":"0.0–1.0 as judged by the vision model."},"tier":{"type":"string","enum":["sure","check"],"description":"`sure` when ≥2 independent vision calls agreed on this item (pre-selected); `check` otherwise."},"known_product":{"anyOf":[{"$ref":"#/components/schemas/ShelfLabelCandidate"},{"type":"null"}]},"matched_by":{"type":["string","null"],"enum":["ean","auto","offer",null],"description":"How the item was matched (SA-0179): its own EAN, the size-aware product auto-match, or the store's chain's current campaign offer (then `offer` is set and `known_product` is null)."},"offer":{"anyOf":[{"type":"object","additionalProperties":false,"required":["id","raw_name","price_cents"],"properties":{"id":{"type":"string","format":"uuid"},"raw_name":{"type":"string"},"price_cents":{"type":"integer"}}},{"type":"null"}]},"candidates":{"type":"array","items":{"$ref":"#/components/schemas/ShelfLabelCandidate"}}}},"ShelfLabelReadResult":{"type":"object","additionalProperties":false,"required":["reading","candidates","items","image_key"],"properties":{"reading":{"$ref":"#/components/schemas/ShelfLabelReading"},"candidates":{"type":"array","items":{"$ref":"#/components/schemas/ShelfLabelCandidate"}},"items":{"type":"array","items":{"$ref":"#/components/schemas/ShelfPhotoReadItem"},"description":"SA-0179 slice 2 — every item the photo read, alongside the (first-priced-item) `reading`/`candidates` above kept for older clients."},"image_key":{"type":"string","description":"The uploaded photo's Blob pathname — not yet accepted by `POST /api/v1/observations` (no image field there today); kept for a future observation-with-photo write."}}},"SteeringEventResult":{"type":"object","additionalProperties":false,"required":["ok","recorded"],"properties":{"ok":{"type":"boolean","enum":[true]},"recorded":{"type":"integer","description":"Number of `involved` rows rolled up."}}},"SteeringEventError":{"type":"object","additionalProperties":false,"required":["error","code"],"properties":{"error":{"type":"string","description":"Equals `code`."},"code":{"type":"string","enum":["missing_auth","invalid_auth","revoked","forbidden_tier","invalid_body","unknown_store","unknown_product"]}}}},"responses":{"BadRequest":{"description":"Invalid parameters (`invalid_params`, `incompatible_params`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Unauthorized":{"description":"Missing, invalid, or revoked API key (`missing_api_key` / `invalid_api_key` / `revoked_api_key`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"Resource not found (`not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ChainNotFound":{"description":"The requested chain_slug does not exist (`chain_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Rate limit or monthly limit reached (`rate_limited` / `monthly_limit_reached`).","headers":{"Retry-After":{"description":"Seconds to wait before retrying (present on `rate_limited`).","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ServiceUnavailable":{"description":"Temporary backend failure (`service_unavailable`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"PayloadTooLarge":{"description":"Decoded image exceeds the 512 KB cap (`payload_too_large`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}