ShelfAtlasAPI docs

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.

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. Free-tier keys have a lifetime cap of 1,000 requests; partner keys have no lifetime cap. Both tiers are also subject to the per-minute rate limit below. 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, category_id, ids, exclude_ids.
GET/api/v1/public/catalog/products/:idSingle product.
GET/api/v1/public/catalog/products/:id/offersCatalogOffers matched to a single product.
GET/api/v1/public/catalog/offersList CatalogOffers. Filter: chain_slug, product_id, product_ids.
GET/api/v1/public/catalog/offers/:idSingle offer.
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/chainsList all Chains (not paginated).

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

Two independent limits apply per API key:

Per-minute rateFree: 60 requests/minPartner: 600 requests/min
Lifetime request capFree: 1,000 requests, everPartner: none

Exceeding the per-minute rate returns 429 rate_limited with a Retry-After header (seconds). Exceeding the free-tier lifetime cap returns 429 monthly_limit_reached. Upgrade your plan to move to the partner tier and remove the lifetime cap.

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_reachedFree-tier key's 1,000-request lifetime cap was reached.
503 service_unavailableA backing service (DB, auth) failed — fail-closed, not a client error.

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" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Tools

list_chainsReturns all retail chains (id, slug, name). No parameters.
get_storeschain_slug?, limit?, cursor?Paginated store list. Filter by chain slug, e.g. "rema".
search_catalog_offerschain_slug?, limit?, cursor?Current catalog offers (promotions and listed products) with price and validity window.
get_product_by_eaneanLook up a product by EAN-13 barcode. Returns product id, name, and ean, or found: false.

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.


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.