ShelfAtlas Catalog API
The ShelfAtlas Catalog API provides read access to normalised Danish retail catalog data — products, offers, stores, and chains — via a simple REST interface.
Data coverage: the offer feed covers every major Danish chain, but the curated product catalog is smaller and actively expanding. Offers only appear under a product (and in price history) once they have been matched to a curated product — an empty data array from those endpoints means "no offers linked yet", not an error. Compare productsCount with liveOffers in the unauthenticated GET /api/v1/public/catalog/stats endpoint for the live numbers.
Authentication
All requests require a valid API key in the Authorization header:
Authorization: Bearer sa_live_<your-key>
Generate a key at app.shelfatlas.com. New keys start on the Free plan — 1,000 requests per calendar month, at no cost. Paid plans raise both the monthly quota and the per-minute rate limit; see pricing. Requests beyond either limit return 429.
Looking for a reference an AI agent can consume directly? See /docs/llms.txt — machine-readable reference for AI agents. The full OpenAPI 3.1 spec describes every endpoint, parameter, and response schema — and is the contract our official SDKs conform to.
Endpoints
Base URL: https://api.shelfatlas.com
GET/api/v1/public/catalog/productsList Products. Filter: chain_slug, brand_id, brand_slug, category_id, ids, exclude_ids, curated, has_image, q.GET/api/v1/public/catalog/products/:idSingle product. :id accepts the product UUID or a bare EAN/GTIN (8/13/14 digits).GET/api/v1/public/catalog/products/:id/offersCurrent CatalogOffers matched to a single product (include_expired=true adds history). Empty = no offers linked yet, not an error.GET/api/v1/public/catalog/offersList current CatalogOffers (campaign offers from leaflets, not everyday shelf prices). Live-only by default; include_expired=true adds history. Filter: chain_slug, brand_slug, product_id, product_ids, ingested_since.GET/api/v1/public/catalog/offers/:idSingle offer.GET/api/v1/public/catalog/offers/searchSearch raw offer titles (Danish full-text + typo-tolerant), grouped by product or item key; optional lat/lng adds the nearest store and shelf status. A group's item_key is what you pass to /offers/history.GET/api/v1/public/catalog/offers/historyPrice series for one item_key across chains and weeks (1–90 days, default 30) with lowest/median context. Advertised leaflet prices, never a normal price.GET/api/v1/public/catalog/price-historyPer-day minimum price series for a set of products.GET/api/v1/public/catalog/storesList Stores. Filter: chain_slug, or lat/lng (+radius_km) for nearest-first geo search.GET/api/v1/public/catalog/stores/:idSingle store.GET/api/v1/public/catalog/stores/:id/stockCommunity shelf-stock signal per product (status, age, confirmations). Empty = no reports yet.GET/api/v1/public/catalog/chainsList all Chains (not paginated).GET/api/v1/public/catalog/statsPlatform-wide catalog aggregates (live offers, stores, chains, per-chain breakdown) — the same numbers on the homepage. Unauthenticated, cached.POST/api/v1/observationsSubmit a shelf observation (stock status, optional price). See "Shelf observations (write)" below.POST/api/v1/steering-eventsFirst-party steering telemetry from partner-tier skins (daily aggregates, no identifiers). Partner keys only — other tiers get 403. Also accepts `kind: "surface_events"` (commercial telemetry — basket adds, trips, chain clicks) on the same route.GET /api/v1/public/catalog/products — scope filters
Four optional query parameters narrow the product list. Each of brand_id, category_id, and ids is a comma-separated list of UUIDs (max 100 entries each). A product matches if it hits any of the values across those three lists (they OR together — category_id matches on the product's primary category). exclude_ids (same CSV/100 format) always wins — a product listed there is removed even if it matched an include list.
curl "https://api.shelfatlas.com/api/v1/public/catalog/products?brand_id=<uuid>&exclude_ids=<uuid>" \ -H "Authorization: Bearer sa_live_<your-key>"
GET /api/v1/public/catalog/offers — product_ids
product_ids is a comma-separated list of up to 100 UUIDs. Returns offers matched (via offer_product_matches) to any of the listed products, cursor-paginated like the rest of this endpoint. product_ids cannot be combined with product_id (the single-product form) or chain_slug — doing so returns 400 incompatible_params.
curl "https://api.shelfatlas.com/api/v1/public/catalog/offers?product_ids=<uuid>,<uuid>" \ -H "Authorization: Bearer sa_live_<your-key>"
GET /api/v1/public/catalog/price-history
Per-day minimum price (in øre, across all chains) for a set of products, over a trailing window. Backed by the same reader used for skin price-history charts — catalog offers and price observations are unioned, so the series reflects both sources.
product_idsCSV UUIDsRequired. 1–50 product ids.daysintegerOptional, 1–90, default 30.curl "https://api.shelfatlas.com/api/v1/public/catalog/price-history?product_ids=<uuid>&days=7" \
-H "Authorization: Bearer sa_live_<your-key>"
{
"ok": true,
"data": [
{ "productId": "<uuid>", "date": "2026-07-01", "minPriceCents": 1295, "chainSlug": "netto" }
]
}No pagination — the response covers every day in range for every requested product.
GET /api/v1/public/catalog/stores — geo search
Returns the formal store payload: id, name, chainSlug, chainName, address, city, postalCode, lat, lng, openingHours, timezone.
chain_slugOptional. Composes with either mode below.lat, lngOptional, but must be given together. Switches to nearest-first geo mode.radius_kmOptional, 0–500. Requires lat/lng. Caps results to this distance.limitOptional, default 50, max 500.curl "https://api.shelfatlas.com/api/v1/public/catalog/stores?lat=55.6761&lng=12.5683&radius_km=5" \
-H "Authorization: Bearer sa_live_<your-key>"
{
"ok": true,
"data": [
{
"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"
}
],
"pagination": { "limit": 50, "cursor": null }
}No cursor pagination on this endpoint — results are either the whole (small) chain list or distance-ordered geo results, and pagination.cursor is always null. An unknown chain_slug narrows to an empty list rather than 404ing, matching how chain_slug filters behave on the other list endpoints.
Pagination
List endpoints support ?limit= (default 50, max 200) and ?cursor= for cursor-based pagination. The response includes pagination.cursor for the next page, or null when no more results.
GET /api/v1/public/catalog/offers?chain_slug=rema&limit=10
{
"ok": true,
"data": [...],
"pagination": { "limit": 10, "cursor": "uuid-of-last-row" }
}Example
curl https://api.shelfatlas.com/api/v1/public/catalog/chains \ -H "Authorization: Bearer sa_live_<your-key>"
Rate limits and quotas
Two independent limits apply per API key, both driven by the key owner's plan — per calendar month (UTC), not lifetime:
Partner (internal skin) keys are not plan-gated: a fixed, generous per-minute budget and no monthly quota. Exceeding the per-minute rate returns 429 rate_limited with a Retry-After header (seconds). Exceeding the monthly request quota returns 429 monthly_limit_reached with the caller's plan, limit, and an upgrade_url in the response body. Upgrade your plan or see the full pricing table.
The Free and Hobby plans are for personal, non-commercial use. If a company is behind the traffic, or the data goes into a product or service you sell, you need the Standard plan or higher. The commercial plans are what buy you notice before this API changes — on the personal plans we may change or rate-limit it at short notice. See the terms.
Every authenticated response tells you where you stand — you never have to wait for the wall to find out. Self-serve keys get three headers on every response, success or error: X-Quota-Limit (your plan's monthly request quota), X-Quota-Remaining (requests left this month, after the one you just made), and X-Quota-Reset (ISO-8601 instant when the quota resets — the start of the next UTC month). Partner keys carry no monthly quota and therefore no quota headers.
We also email the key owner once at 80% of the monthly quota and once when it is reached, at the address on their ShelfAtlas account.
Error codes
400 invalid_paramsA query parameter failed validation (bad UUID, out-of-range number, list over its cap, etc).400 incompatible_paramsTwo mutually-exclusive parameters were combined (e.g. product_id with product_ids, or either with chain_slug on /offers).401 missing_api_keyNo Authorization header was sent.401 invalid_api_keyThe key is malformed or not recognised.401 revoked_api_keyThe key was revoked by its owner or ShelfAtlas.404 not_foundSingle-resource lookup (e.g. /products/:id) found no matching row.429 rate_limitedPer-minute request budget exceeded. See Retry-After header.429 monthly_limit_reachedThe caller's plan reached its monthly request quota. The body carries plan, limit, used, resets_at, a human-readable message, next_plan, plans_url and upgrade_url.503 service_unavailableA backing service (DB, auth) failed — fail-closed, not a client error.Data accuracy & ownership
Catalog data is extracted from publicly published retailer leaflets and provided as-is, without warranty. Product names, logos, trademarks and leaflet imagery belong to their respective owners; the structured catalog is the work of ShelfAtlas. Prices and validity periods may contain errors — the retailer's own printed leaflet always prevails. Verify price-critical decisions against the retailer's published material.
Shelf observations (write)
POST /api/v1/observations records a crowd-sourced shelf signal — stock status, and optionally a price — for a product or offer at a store.
Who can write
Any API key — free or partner tier — can submit an observation with the usual Authorization: Bearer sa_live_<your-key> header. Callers without a key can submit anonymously with an X-Device-Token header: a UUID you generate and keep client-side to represent this device — any UUID version is accepted, the check is purely the 8-4-4-4-12 hex-digit shape. Sending both an Authorization header and an X-Device-Token header uses the API key. Contributing observations earns no reward and no extra requests — it is not coupled to your quota in either direction, beyond the normal write-endpoint rate limit below.
Request body
Exactly one of ean or offer_id is required — the other is omitted.
eanstring, optional. EAN/GTIN of a curated product. Required if offer_id is omitted.offer_idUUID, optional. A CatalogOffer id. Required if ean is omitted.store_idUUID, required.stock_statusenum, required: "in_stock" | "low_stock" | "out_of_stock".price_centspositive integer, optional. The observed price, in cents.currencystring, 3 characters, optional. Defaults to "DKK".consent_tierenum, optional: "anonymous_aggregate" (default) | "identifiable".reporter_clerk_idstring, optional. Attributes the observation to an end-user your surface has already verified server-side. Honoured only for API-key auth.device_tokenUUID, optional. 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_atISO-8601 datetime string, optional. When the shelf was actually seen, for a report submitted after the fact instead of at request time. Must not be more than 5 minutes in the future or more than 48 hours in the past. Omit to stamp the current time (unchanged default).curl -X POST https://api.shelfatlas.com/api/v1/observations \
-H "Authorization: Bearer sa_live_<your-key>" \
-H "Content-Type: application/json" \
-d '{
"store_id": "3f2a9c10-2b34-4e2a-9c3e-1a2b3c4d5e6f",
"ean": "5701234567890",
"stock_status": "out_of_stock"
}'Response
201on success, with the observation's id and moderation outcome:
{
"id": "<uuid>",
"moderation_status": "approved" | "pending_review"
}An observation auto-approves (moderation_status: "approved") when it comes from a trusted source: any request authenticated with a partner-tier API key, or any caller whose device already has 5 or more approved observations. Everything else starts as pending_review. A submitted price that is a large outlier against the product's known catalog prices is always held for review, even from a trusted source. Only approved observations feed the stock fields returned by GET /api/v1/public/catalog/stores/:id/stock today.
An API-key request that resolves to an identified reporter — via reporter_clerk_id or the key's own owner — may additionally receive a top_reporter object in the response; treat any field not documented above as optional.
Rate limits
Every request to this endpoint, regardless of how it is authenticated, shares the platform's write-route budget: 10 requests/minute per IP (separate from the read-endpoint numbers in Rate limits and quotas above, which don't apply here). An anonymous X-Device-Token caller additionally gets its own budget of 50 requests per 15 minutes, checked twice — once per IP and once per device token — so a freshly generated device id can't be used to dodge the IP-level limit. Submitting via an API key does notcount against the key's monthly request quota and does not carry the X-Quota-* headers described above — this endpoint is not plan-gated.
Errors
Unlike the rest of the Catalog API (which returns only error), this endpoint's error body carries both error and code — the same value in both fields. See Error codes above for what each HTTP status means; the codes specific to this endpoint are:
401 missing_authNeither an Authorization header nor an X-Device-Token header was sent.401 invalid_authThe API key is malformed/unrecognised, or the device token is not a valid UUID.401 revokedThe API key was revoked.429 rate_limit_exceededEither the shared per-IP write-route limit (10 req/min, checked in middleware before this handler runs, for every caller regardless of auth) or the anonymous (X-Device-Token) caller-specific allowance above was exceeded — checked once per IP and once per device token. Same shape as this endpoint's own errors; body includes retryAfterSec.400 invalid_bodyBody failed validation — 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.422 unknown_eanNo curated product matches the given ean.422 unknown_offerNo CatalogOffer matches the given offer_id.422 unknown_storeNo store matches the given store_id.MCP Server
ShelfAtlas exposes the same catalog data as an MCP (Model Context Protocol) server, so AI assistants like Claude Desktop, Cline, and Cursor can query Danish retail data directly. One endpoint, stateless JSON transport, same API key.
Endpoint
POST https://api.shelfatlas.com/api/v1/mcp
Authentication is identical to the REST API — pass your key in the Authorization header. Query-string auth is not supported.
curl https://api.shelfatlas.com/api/v1/mcp \
-H "Authorization: Bearer sa_live_<your-key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'Tools
list_chains—Returns all retail chains (id, slug, name). No parameters.get_storeschain_slug?, lat?, lng?, radius_km?, limit?, cursor?Paginated store list, or nearest-first geo search when lat/lng are passed (geo results are not paginated).search_catalog_offerschain_slug?, ean?, product_id?, include_expired?, limit?, cursor?Catalog offers with price and validity window. Only currently-valid offers unless include_expired.find_cheapest_offersean | product_id, limit?One call for 'where is this product cheapest right now?' — live offers, cheapest first, chain slug/name resolved.get_product_by_eaneanLook up a product by EAN-13 barcode. Returns product id, name, and ean, or found: false.search_productsq, limit?Resolve a product NAME to its id and EAN before calling find_cheapest_offers or search_catalog_offers. Case-insensitive substring match on the canonical name.Pagination: tools that accept cursor return nextCursor in the response. Pass it as cursor on the next call. Default limit 20, max 50.
Claude Desktop configuration
Add the following to your claude_desktop_config.json:
{
"mcpServers": {
"shelfatlas": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://api.shelfatlas.com/api/v1/mcp"],
"env": {
"MCP_REMOTE_HEADER_AUTHORIZATION": "Bearer sa_live_<your-key>"
}
}
}
}Uses mcp-remote to bridge the HTTP endpoint to Claude Desktop's stdio transport. No local server required.
Claude Code
Claude Code speaks HTTP MCP natively — no bridge process:
claude mcp add --transport http shelfatlas https://api.shelfatlas.com/api/v1/mcp \ --header "Authorization: Bearer sa_live_<your-key>"
Cursor / Cline / other MCP clients
Any client that supports stdio MCP servers can use the same mcp-remotebridge as Claude Desktop — the JSON block above works unchanged in Cursor's .cursor/mcp.jsonand Cline's MCP settings. Clients that support remote HTTP servers directly can point straight at the endpoint with the same Authorization header.
Try asking
With the server connected, questions like these resolve against live catalog data:
- “Where is Pepsi Max cheapest in Odense right now?”
- “Which chains have it on offer this week, and until when?”
- “Find the nearest REMA 1000 that carries it.”
- “Look up EAN 5741000124123 and show its current offers.”
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 as they are published, without polling. Create and manage subscriptions at app.shelfatlas.com/app/webhooks.
The payload
When an ingest run creates new offers, we send one POST per subscription carrying every offer that run created (batched — never one request per offer). The body:
{
"event": "offer.upserted",
"occurred_at": "2026-07-12T10:00:00.000Z",
"delivery_id": "0f9c…", // unique per delivery — use as an idempotency key
"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
}
}
]
}Signing secret & verification
Each subscription has its own signing secret, shown once when you create it (save 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 to confirm the request came from ShelfAtlas:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, headerSig, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(headerSig);
return a.length === b.length && timingSafeEqual(a, b);
}Retries & delivery guarantees
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. Because a request can be retried, treat delivery_id as an idempotency key and 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. See /status for live per-chain catalog freshness and webhook-delivery health.