# ShelfAtlas Catalog API > Read access to normalised Danish retail catalog data — products, offers, > stores, and chains — via a simple REST interface. Base URL: `https://api.shelfatlas.com` All paths below are relative to the base URL, e.g. `GET /api/v1/public/catalog/products`. ## Authentication Every request requires an API key in the `Authorization` header: ``` Authorization: Bearer sa_live_ ``` Query-string auth is not supported. Get a key from the developer dashboard at `https://app.shelfatlas.com/app/keys/new` (sign-in required). ## Data coverage The offer feed covers every major Danish chain, but the curated product catalog is smaller and actively expanding: an offer only appears under `/products/{id}/offers` and `/price-history` once it has been matched to a curated product. An empty `data` array from those endpoints therefore usually means "no offers linked to this product yet" — it is **not an error**, and it does not mean the product is inactive. For live counts, `GET /api/v1/public/catalog/stats` (unauthenticated, cached ~1 hour) reports `productsCount` (curated products) alongside `liveOffers` (all ingested offers) — the gap between the two is the matching backlog. `/offers` always returns the full ingested feed, matched or not. App developers: https://shelfatlas.com/docs/app-developers answers coverage, fields, sync, licence and plans with live numbers, plus a live worked example (a week's meal plan — 12 basics — priced against two chains via `/offers/sample`). ## Pagination Most list endpoints are cursor-paginated: - `limit` — page size, coerced from the query string, default 50, max 200 (`/stores` is the exception: max 500, see below). - `cursor` — opaque string, max 512 chars. Pass the previous response's `pagination.cursor` to fetch the next page. Response envelope: ```json { "ok": true, "data": [ /* ... */ ], "pagination": { "limit": 50, "cursor": "uuid-of-last-row-or-null" } } ``` `pagination.cursor` is `null` when there are no more results. `GET /chains` is not paginated (small, fixed set). `GET /stores` and `GET /price-history` do not use cursor pagination either — see their sections below for why. ## Endpoints ### GET /api/v1/public/catalog/products List products. Query parameters (all optional): | param | type | constraint | |---|---|---| | `chain_slug` | string | lowercase letters/digits/hyphens, max 64 chars | | `brand_id` | CSV UUIDs | max 100 entries | | `category_id` | CSV UUIDs | max 100 entries; matches the product's primary category | | `ids` | CSV UUIDs | max 100 entries | | `exclude_ids` | CSV UUIDs | max 100 entries | | `cursor` | string | max 512 chars | | `limit` | integer | default 50, max 200 | Scope filter semantics: `brand_id`, `category_id`, and `ids` OR together — a product matches if it hits *any* value across those three lists. `exclude_ids` always wins (applied as NOT IN after the include match). Response `data[]` item (16 fields): `id`, `canonicalName`, `variant`, `brandId`, `primaryCategoryId`, `imageUrl`, `ean`, `unitCount`, `volumeMl`, `isActive`, `createdAt`, `updatedAt`, `packageType`, `packageCount`, `packageSizeValue`, `packageSizeUnit`. ### GET /api/v1/public/catalog/products/{id} Single product. `{id}` accepts either the ShelfAtlas product UUID or a bare EAN/GTIN barcode (8, 13, or 14 digits) — no separate lookup endpoint needed. 404 `not_found` if no matching row. ### GET /api/v1/public/catalog/products/{id}/offers CatalogOffers matched to a single product (via offer_product_matches). Same cursor pagination as `/offers`. An empty page means no offers are linked to the product yet (see **Data coverage** above), not an error. ### GET /api/v1/public/catalog/offers List CatalogOffers. **Live-only by default**: only offers whose `validTo` has not passed (current and upcoming campaigns). The data set is campaign offers from chain leaflets, not everyday shelf prices — staples that are rarely on offer (eggs, sugar, milk) will often have no current offer. Query parameters (all optional): | param | type | constraint | |---|---|---| | `product_id` | UUID | single product; incompatible with `chain_slug` and `product_ids` | | `product_ids` | CSV UUIDs | max 100 entries; incompatible with `product_id` and `chain_slug` | | `chain_slug` | string | incompatible with `product_id` and `product_ids` | | `cursor` | string | max 512 chars | | `limit` | integer | default 50, max 200 | | `ingested_since` | ISO 8601 date/datetime | only offers ingested at or after this timestamp (incremental sync) | | `include_expired` | `true` / `false` | default `false`; `true` also returns expired offers (history) | Sync gap to know: `productId` can appear on an offer after it was ingested (a product link made later). That does not change the offer's `ingestedAt`, so an `ingested_since` sync does not return the offer again (offers have no `updated_at`). Re-fetch with `/offers?product_id=` for the products you follow, or do a full `chain_slug` sync once a week; `itemKey` never changes. `product_ids` returns offers matched to *any* of the listed products (via offer_product_matches), de-duplicated (one offer can match several listed products). Combining `product_id`/`product_ids`/`chain_slug` with each other returns `400 incompatible_params`. Response `data[]` item: an explicit allow-list (id, chainId, storeId, productId, rawName, price, originalPrice, unitPrice, unitPriceUnit (l/kg/stk), currency, validFrom, validTo, ingestedAt, imageUrl, volumeMl, unitCount, itemKey — `productId` is the offer's best link to a curated product (null when unlinked; see the per-chain linking rate on /docs/app-developers) and `itemKey` the offer's stable item identity, the same key `/offers/search` groups by and `/offers/history?item_key=` takes — plus `matchedProductIds` on product-scoped queries and `cropUrl`, our own leaflet crop of the offer, on `chain_slug` queries), not the raw table row. Every item also carries `previousPrice` (what the same item cost in the same chain's previous catalogue, a decimal string like `price`, or `null` when unknown) and `isNewThisWeek` (started in the last 7 days and not offered by the chain in the 21 days before) — facts to write your own "price fell" / "new this week" copy from. ### GET /api/v1/public/catalog/offers/{id} Single offer by UUID. 404 `not_found` if no matching row. ### GET /api/v1/public/catalog/offers/search Danish full-text + typo-tolerant search over raw offer titles, grouped by the curated product when one is known and otherwise by a stable item key. Query parameters: | Parameter | Type | Meaning | | --- | --- | --- | | `q` | string (2–64) | **required**; search text, e.g. `pepsi max` | | `lat`, `lng` | number | must be given together; adds nearest store + shelf status per chain | | `radius_km` | number (1–100) | default `15`; only meaningful with lat/lng | | `limit` | integer (1–50) | default `20`; offers per page (groups may be fewer) | | `cursor` | string | opaque keyset cursor from `next_cursor` | | `order` | `score` | `relevance` | default `score` = deal order (savings, price, nearby stock); `relevance` = search rank — use it when matching a shopping list or recipe, so "mælk" lands on milk, not on the best deal that mentions it | Each group carries `key`, `title`, `item_key` and `product_id`. **`key` is opaque** — `p:` when the group resolved to a curated product, else `k:`. Compare it; never parse it. `item_key` is the group's persisted item identity and is what you pass to `/offers/history`. An offer's `image_url` is the matched product's packshot, inherited across the item key — so an offer with no match of its own can still carry the image of the same item matched in another chain or week. An offer's `crop_url`, when present, is our own verified crop of that offer's leaflet tile — shown only for chains where leaflet crops are switched on; `null` otherwise, including chains that are off. When that picture is the whole leaflet page, `page_box` is the offer's cell on it (`{x1,y1,x2,y2}` in page-width units) so a client can zoom in; `null` otherwise. ### GET /api/v1/public/catalog/offers/sample Your shopping list against your chains in one call: for each search term and each chain, the top 3 current offers in search-relevance order. Built for a first look at the data — one request, one quota unit. Query parameters: | Parameter | Type | Meaning | | --- | --- | --- | | `items` | CSV strings (1–12, each 2–64 chars) | **required**; search terms, e.g. `mælk,æg,ris`; duplicate items are collapsed to one | | `chains` | CSV chain slugs (1–3) | **required**; e.g. `netto,rema`; duplicate chains are collapsed to one | ```bash curl -H "Authorization: Bearer sa_live_" \ "https://api.shelfatlas.com/api/v1/public/catalog/offers/sample?items=m%C3%A6lk,%C3%A6g,ris&chains=netto,rema" ``` Response: `{ ok: true, data: { generated_at, chains, items, not_on_offer } }`. `chains` is `[{ chain_slug, chain_name }]`; `items` is `[{ query, per_chain: { : Offer[] } }]` with each `Offer` the same row as `/offers` (at most 3 per chain); `not_on_offer` is `[{ query, chain_slug }]` — the item × chain pairs with nothing on offer, listed explicitly (an empty `per_chain` array is not an error). Unknown parameters return `400 unknown_parameter`; out-of-range or malformed values return `400 invalid_params`; an unknown or inactive chain slug returns `404 chain_not_found`. Responses are cached for 5 minutes. Same data as `/offers`; move to `/offers?chain_slug=…&ingested_since=…` for sync. ### GET /api/v1/public/catalog/offers/history Price series for one item key across chains and the last `days` days, oldest first, with lowest/median context. Query parameters: | Parameter | Type | Meaning | | --- | --- | --- | | `item_key` | string (1–200) | **required**; the `item_key` of a group from `/offers/search` | | `days` | integer (1–365) | default `30`; look-back window (use `days=180` for `summary.offer_pattern`) | `summary` (`lowest_cents`, `median_cents`, `chains`, `weeks_seen`) 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 an empty result, not an error. `summary.offer_pattern` (days: `share_of_days_on_offer`, `days_on_offer`, `days_total`, `periods`, `typical_duration_days`, `typical_gap_days`, `on_offer_now`, `days_since_last_offer`, `next_expected_in_days`) says how often and how long the item is on offer, from the same campaign rows merged across chains. It is `null` unless there are at least 3 offer periods in a window of at least 84 days — ask for `days=180` (history starts 2026-04-13, so `days=365` is diluted until April 2027). Medians of the past, not a forecast. ### GET /api/v1/public/catalog/offers/price-drops This week's biggest price drops: active campaign offers cheaper than the same item (a persisted item key that includes a pack size) in the same chain's previous catalogue, largest absolute drop first, one row per item and chain. Only offers that started in the last 7 days count. Query parameters: | Parameter | Type | Meaning | | --- | --- | --- | | `limit` | integer (1–20) | default `5` | | `chain_slug` | string | one chain only; `404 chain_not_found` if unknown | | `lat`, `lng` | number | together; only chains with an active store nearby | | `radius_km` | number (1–100) | default `15`; radius around lat/lng | Response `data[]` item: the offer item shape above plus `dropAmount` (`previousPrice − price`, decimal string) and `dropPercent` (one decimal). An empty list means no drops this week, not an error. ### GET /api/v1/public/catalog/offers/term-history Weekly price history for everything matching a search **term** (the same matcher as `/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. Query parameters: | Parameter | Type | Meaning | | --- | --- | --- | | `q` | string (2–64) | **required**; search text, e.g. `pepsi max` | | `weeks` | integer (1–26) | default `26`; look-back window in ISO weeks | | `chain_slug` | string | optional; only that chain's own offers (its own typical price, cheapest week, weeks on offer); unknown slug is 404 `chain_not_found` | One entry per ISO week (Monday, 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"). `min_price`/`typical_price`/`lowest_price` are SHELF prices across pack sizes — a 33 cl can at 3 kr "beats" a 1.5 l bottle at 12 kr. For a fair "typical" or "cheapest since" claim use the unit basis: `summary.unit` (`l`/`kg`/`stk`, the unit most offers carry; `null` = no common unit, make no claim), `summary.typical_unit_price`, `summary.lowest_unit_price` (+ `_week_start`, `_chain_name`) and per week `cheapest_unit_price` (+ `_chain_slug`, `_chain_name`), all in DKK per that unit. `summary.offer_pattern` (weeks: `share_of_weeks_on_offer`, `weeks_on_offer`, `weeks_total`, `periods`, `typical_duration_weeks`, `typical_gap_weeks`, `on_offer_now`, `weeks_since_last_offer`, `next_expected_in_weeks`) is the same pattern over CAMPAIGN weeks; `null` unless there are at least 3 offer periods in at least 12 weeks (a `weeks=8` request is always `null`). Medians of the past, not a forecast. ### GET /api/v1/public/catalog/price-history Per-day minimum price (in øre/cents, across all chains) for a set of products, over a trailing window. Sourced from a UNION of catalog offers and price observations. Query parameters: | param | type | constraint | |---|---|---| | `product_ids` | CSV UUIDs | **required**, 1–50 entries | | `days` | integer | optional, 1–90, default 30 | Response shape — no pagination, one row per product per day with data: ```json { "ok": true, "data": [ { "productId": "", "date": "2026-07-01", "minPriceCents": 1295, "chainSlug": "netto" } ] } ``` ### GET /api/v1/public/catalog/stores List stores, or search nearest-first by coordinates. Query parameters (all optional): | param | type | constraint | |---|---|---| | `chain_slug` | string | composes with either mode below | | `lat` | number | -90..90; must be given together with `lng` | | `lng` | number | -180..180; must be given together with `lat` | | `radius_km` | number | 0–500; requires `lat`/`lng` | | `limit` | integer | default 50, max 500 | Mode selection: if `lat` and `lng` are both present, results are nearest-first (geo mode), optionally capped to `radius_km`. Otherwise this is a plain list, optionally filtered by `chain_slug`. Response `data[]` item — formal store payload: ```json { "id": "", "name": "Netto Østerbro", "chainSlug": "netto", "chainName": "Netto", "address": "Østerbrogade 1", "city": "København", "postalCode": "2100", "lat": 55.7, "lng": 12.58, "openingHours": null, "timezone": "Europe/Copenhagen" } ``` No cursor pagination: `pagination.cursor` is always `null`; `limit` is the only page-size control (dataset is small; geo results are distance-ordered, not id-ordered, so a cursor would not compose with either mode). An unknown `chain_slug` narrows to an empty list, **not** a 404 — this is a convention change from earlier versions of this endpoint, matching how `chain_slug` behaves as a filter elsewhere in this API. Only single-resource lookups by path id (e.g. `/stores/{id}`) 404 on an unknown id. ### GET /api/v1/public/catalog/stores/{id} Single store by UUID. 404 `not_found` if no matching row. (Returns the raw store row, not yet the formal payload above — that shape currently only applies to the list endpoint.) ### GET /api/v1/public/catalog/stores/{id}/stock Community shelf-stock signal for one store: latest approved observation per curated product — `stockStatus` (in_stock/low_stock/out_of_stock), `observedAt`, `validUntil` (expired = "not reported"), and `confirmations` (independent reporters within 24h; 1 = unconfirmed — render as "possibly out of stock", never certainty), `confidence` (confirmed/reported) and `trusted` (false = only new/unestablished sources, e.g. one first-ever report; label it "one report"). Empty `products` means no reports yet, not an error. ### GET /api/v1/public/catalog/stores/{id}/local-prices A local (crowd-observed) shelf price at this store — SA-0162. Data-separated from CatalogOffer: this is never a discount/leaflet claim, only what someone saw on the shelf. Latest APPROVED, non-outlier observation per product that carries a price; a stock-only observation never appears here (see `/stock` above for that). Open to any catalog key (no coverage-package grant required); tier gating on freshness is a pricing decision not yet built. 404 `not_found` for an unknown store. Response `data`: `{ storeId, store{id,name,chainSlug}, prices[] }` where each price row is `{ ean, label, priceCents, stockStatus, observedAt, trust }` — `label` is null when the ean has no curated product, `trust` is `partner` | `established` | `new` (source class, never reporter identity). RAW facts only: `observedAt` carries no server-computed age (the response is CDN-cached). ### GET /api/v1/public/catalog/local-prices Local (crowd-observed) shelf prices near a point — SA-0164, the radius-wide sibling of `/stores/{id}/local-prices`. Same data-separation from CatalogOffer and same approved/non-outlier/priced gate, scoped by `lat`/`lng` (required) and `radius_km` (1–100, default 15) instead of one store id. Deduped to the latest observation per (store, product), newest first, capped by `limit` (1–100, default 20). Open to any catalog key. Response `data`: `{ items[], summary }`. Each `items[]` row is `{ ean, label, priceCents, stockStatus, observedAt, trust, store{id,name,chainSlug,chainName}, distanceKm }` — `distanceKm` is the great-circle distance from lat/lng, rounded to 0.1. `summary` is `{ today{prices,stores}, week{prices,contributors} }` over the SAME radius: `today` since local Copenhagen midnight, `week` a rolling 7 days; `contributors` is a count only, never a reporter id. RAW facts only: `observedAt` carries no server-computed age (the response is CDN-cached). ### GET /api/v1/public/catalog/chains List all chains. Not paginated. `data[]` item: `{ id, slug, name, displayName, country, logoUrl }`. ### GET /api/v1/public/catalog/stats Platform-wide catalog aggregates — 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`). No parameters. Response `data`: `{ liveOffers, storesCount, chainsCovered, productsCount, priceHistoryDays, perChain[], recentOffers[], latestOffersAcrossChains[], generatedAt }` (`latestOffersAcrossChains`: newest current campaign offers, at most 2 per chain). ### GET /api/v1/public/catalog/observed-prices The B2B read (Shelf Intelligence): the latest APPROVED shelf observation per product per store, limited to the coverage packages your API key has been granted (a package = a customer's stores × products kept observed by the field team). 403 `no_coverage_package` without a grant. Filters: `ean`, `store_id`, `since`; `limit` 1–500 (default 200), `offset`. Rows: `{ ean, label, store{id,name,chainSlug,chainName,lat,lng}, priceCents, stockStatus, observedAt, trust }` where `trust` is `partner` | `established` | `new`. Private, never cached; no reporter identity. ### GET /api/v1/public/catalog/seo-terms The generated SEO term list (SA-0137), refreshed daily from currently-active campaign offers — backs Sparekurven's `/tilbud/` 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`). Query parameters (all optional): | param | type | constraint | |---|---|---| | `min_offers` | integer | default `3`; only terms with at least this many offers | | `min_chains` | integer | default `2`; only terms carried by at least this many distinct chains | | `limit` | integer | default `1000`, max `1000` | Response `data[]` item: `{ slug, term, query, offers_7d, chains_7d, chain_offers, last_seen }`. `chain_offers` is `[{ chain_slug, offers }]` (offers desc, then slug asc; sums to `offers_7d`, length `chains_7d`) or `null` until the term's next daily refresh. Campaign offers are chain-level, so sum the chains near a place to size a place × term page without one call per pair. ### POST /api/v1/public/catalog/shelf-label/read Reads a shelf-edge price label from a photo (SA-0165): one vision call returns `{ name, price_cents, ean, confidence, reason }` plus catalog `candidates` matching what was read (by EAN if legible, otherwise a name search). Body: `{ image_base64, content_type }` where `content_type` is one of `image/jpeg`/`image/png`/`image/webp` and the decoded image must be at most 512 KB (`413 payload_too_large` above that — downscale client-side first). An unreadable label is still a `200`: `reading.reason: "unreadable"` and `candidates: []`, since it consumed the vision call same as a readable one. `image_key` is the uploaded photo's Blob pathname (not yet accepted by `POST /api/v1/observations`, which has no image field today). Auth: the same key-only, quota-metered gate as every other Catalog API route above — no anonymous device-token option, since every call costs one vision call. `503 service_unavailable` if the vision deployment or Blob storage is unavailable. ### POST /api/v1/observations Submit a shelf observation (stock status, optional price, optional `observed_at` for a report made after the fact — no more than 48h in the past) for a product or offer at a store. Auth: `Authorization: Bearer sa_live_` (any tier) OR an `X-Device-Token: ` header for anonymous callers — not the Catalog API's key-only auth above. Full request/response/error reference: https://shelfatlas.com/docs#observations. ### POST /api/v1/steering-events First-party steering telemetry: a partner's consumer surface posts one row per candidate chain every time it localizes a "best choice" card, so a shopper's chosen store can be compared against the nearest store per candidate chain. Body: `product_id`, `winning_store_id`, optional `visitor_key` (opaque per-device uuid, for unique-visitor counting only), and `involved[]` (1–25 entries) of `{ nearest_store_id, distance_band, price_gap_cents }` — `distance_band` is a client-computed bucket (`<1`/`1-3`/`3-10`/`>10`), never a raw coordinate. `202` on success: `{ ok: true, recorded }`. Auth: `Authorization: Bearer sa_live_`, **partner-tier keys only** — every other tier gets `403 forbidden_tier`. This is used by ShelfAtlas' own consumer surfaces; not a general-purpose endpoint. The same route also accepts a second payload shape, `kind: 'surface_events'`: a consumer surface's commercial telemetry (basket adds, generated trips, store selections, outbound clicks to a chain). Body: `kind: 'surface_events'`, `surface` (currently only `'sparekurven'`), optional `visitor_key`, and `events[]` (1–25 entries) of `{ type: 'basket_add'|'trip_generated'|'store_selected'|'chain_click', chain_slug?, store_id?, offer_id?, savings_cents?, total_km?, stops?: [{ chain_slug, store_id, subtotal_cents }] (1–10 entries), target?: 'store'|'chain'|'offer' }`. Chain slugs and store ids are resolved and existence-checked server-side; an event whose chain or store is unknown is dropped rather than rejecting the whole call. `202` on success: `{ ok: true, recorded, dropped }`. Same auth and partner-tier-only rule as above. ## Webhooks (beta) A webhook subscription registers an HTTPS endpoint of yours to receive signed `POST` requests when catalog events happen, so you can react to new offers without polling. Create and manage subscriptions at `https://app.shelfatlas.com/app/webhooks` (sign-in required). When an ingest run creates new offers, one `POST` per subscription is sent carrying every offer that run created (batched, never one request per offer). Body: ```json { "event": "offer.upserted", "occurred_at": "2026-07-12T10:00:00.000Z", "delivery_id": "0f9c…", "data": [ { "change": "created", "offer": { "id": "…", "rawName": "Pepsi Max 24x33cl", "price": "99.00", "currency": "DKK", "validFrom": "2026-07-01T00:00:00.000Z", "validTo": "2026-07-08T00:00:00.000Z", "chainId": "…", "storeId": null, "productId": "…", "volumeMl": 330, "unitCount": 24, "imageUrl": null, "originalPrice": null, "discountPct": null, "unitPrice": null, "unitPriceUnit": null } } ] } ``` `delivery_id` is unique per delivery: use it as an idempotency key. **Verification.** Each subscription has its own signing secret, shown once when you create it (it is not retrievable later). Every delivery carries an `X-ShelfAtlas-Signature` header: `sha256=` followed by the HMAC-SHA256 of the *raw request body* keyed with your secret. Recompute it and compare in constant time. **Retries.** Delivery is at-least-once. A delivery is retried on any non-2xx response, timeout, or network error with an escalating backoff (roughly 1m, 5m, 30m, 2h, 6h, 24h) for up to six attempts before it is marked failed. Return a 2xx quickly. Deliveries are still in beta: dispatched by a background job that runs every 15 minutes, so expect up to ~15 minutes of latency between an offer appearing and its delivery arriving. Live webhook-delivery health: `https://shelfatlas.com/status`. ## Rate limits and quotas Two independent limits apply per API key, both driven by the key owner's plan — per UTC calendar month, not lifetime. Full pricing and upgrade flow: https://shelfatlas.com/pricing. | Plan | Price | Requests/month | Per-minute rate | |---|---|---|---| | Free | Free | 1,000 | 60/min | | Hobby | 49 kr./mo | 5,000 | 120/min | | Standard | 649 kr./mo | 100,000 | 300/min | | Growth | 2499 kr./mo | 500,000 | 600/min | | Scale | 4599 kr./mo | 1,000,000 | 1200/min | Partner-tier keys are not plan-gated: a fixed, generous per-minute budget and no monthly quota. **The Free and Hobby plans are for personal, non-commercial use by an individual.** Use by or on behalf of a company, or inside a product or service you sell, requires the Standard plan or higher. On the personal plans the API may change or be rate-limited at short notice; the commercial plans are what buy notice before it does. See https://shelfatlas.com/legal/terms. - Exceeding the per-minute rate → `429 rate_limited`, with a `Retry-After` header in seconds. - Write endpoints (`POST /api/v1/observations`, `POST /api/v1/steering-events`) are budgeted 10/min per IP for anonymous or device-token callers, and 60/min PER KEY (not per IP) when the request carries a valid `sa_live_` key — so a partner server's shared egress IP never starves its own writes. Reads stay 60/min per IP regardless of a key. - Exceeding the monthly request quota → `429 monthly_limit_reached`, body `{ error, plan, limit, used, resets_at, message, next_plan, plans_url, upgrade_url }`. Subscribe or change plans at https://shelfatlas.com/pricing, or `https://app.shelfatlas.com/app/billing` once signed in. Every authenticated response from a self-serve key carries the caller's standing, so you never have to hit the wall to discover it: | header | meaning | |---|---| | `X-Quota-Limit` | The plan's monthly request quota. | | `X-Quota-Remaining` | Requests left this UTC month, after the current one. | | `X-Quota-Reset` | ISO-8601 instant the quota resets (next UTC month start). | Partner keys have no monthly quota and therefore no quota headers. The key owner is also emailed once at 80% of the quota and once when it is reached. ## Error codes | code | status | meaning | |---|---|---| | `invalid_params` | 400 | A query parameter failed validation (bad UUID, out-of-range number, list over its cap, malformed CSV list, etc). | | `incompatible_params` | 400 | Two mutually-exclusive parameters were combined (e.g. `product_id` with `product_ids`, or either with `chain_slug` on `/offers`). | | `missing_api_key` | 401 | No `Authorization` header was sent. | | `invalid_api_key` | 401 | The key is malformed or not recognised. | | `revoked_api_key` | 401 | The key was revoked by its owner or ShelfAtlas. | | `not_found` | 404 | Single-resource lookup found no matching row. | | `rate_limited` | 429 | Per-minute request budget exceeded. See `Retry-After` header. | | `monthly_limit_reached` | 429 | The caller's plan reached its monthly request quota. Body carries `plan`, `limit`, `used`, `resets_at`, `message`, `next_plan`, `plans_url`, `upgrade_url`. | | `service_unavailable` | 503 | A backing service (DB, auth) failed. This API fails closed, never returns 200 on an auth/secret error. | ## MCP server (use ShelfAtlas in an AI assistant) The same catalog is exposed as a Model Context Protocol server at `POST https://api.shelfatlas.com/api/v1/mcp` (stateless JSON-RPC 2.0, same `Authorization: Bearer` key as the REST API). Requests MUST send `Accept: application/json, text/event-stream` (Streamable HTTP transport) or the server answers -32000 Not Acceptable. Tools: `list_chains` (no args); `get_stores` (chain_slug?, lat?, lng?, radius_km?, limit?, cursor? — lat/lng gives a nearest-first geo search); `search_catalog_offers` (chain_slug?, ean?, product_id?, include_expired?, limit?, cursor? — only currently-valid offers unless include_expired); `find_cheapest_offers` (ean | product_id, limit? — live offers for one product, cheapest first, chain slug/name resolved); `get_product_by_ean` (ean). Client setup: - Claude Code: `claude mcp add --transport http shelfatlas https://api.shelfatlas.com/api/v1/mcp --header "Authorization: Bearer "` - Claude Desktop / Cursor / Cline (stdio clients): bridge with `npx -y mcp-remote https://api.shelfatlas.com/api/v1/mcp` and set `MCP_REMOTE_HEADER_AUTHORIZATION`. Full walkthrough: https://shelfatlas.com/docs (MCP Server section). ## Getting a key Sign in at `https://app.shelfatlas.com` and create a key from the developer dashboard (`/app/keys/new`). New keys start on the Free plan; upgrade any time at https://shelfatlas.com/pricing, or contact ShelfAtlas to move to the partner tier.