# 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_<your-key>
```

Query-string auth is not supported. Get a key from the developer dashboard
at `https://app.shelfatlas.com/app/keys/new` (sign-in required).

## 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: the full Product row (id, name, ean, brandId,
primaryCategoryId, and other product fields).

### GET /api/v1/public/catalog/products/{id}

Single product by UUID. 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`.

### GET /api/v1/public/catalog/offers

List CatalogOffers.

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 |

`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: the full CatalogOffer row (id, productId, chainId,
price, validFrom, validTo, and other offer fields).

### GET /api/v1/public/catalog/offers/{id}

Single offer by UUID. 404 `not_found` if no matching row.

### 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": "<uuid>", "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": "<uuid>", "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/chains

List all chains. Not paginated. `data[]` item: `{ id, slug, name, displayName, country, logoUrl }`.

## Rate limits and quotas

Two independent limits apply per API key:

| | Free tier | Partner tier |
|---|---|---|
| Per-minute rate | 60 requests/min | 600 requests/min |
| Lifetime request cap | 1,000 requests, ever | none |

- Exceeding the per-minute rate → `429 rate_limited`, with a
  `Retry-After` header in seconds.
- Exceeding the free-tier lifetime cap → `429 monthly_limit_reached`.
  Upgrading to the partner tier removes the lifetime cap (the per-minute
  rate still applies, at the higher partner ceiling).

## 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 | Free-tier key's 1,000-request lifetime cap was reached. |
| `service_unavailable` | 503 | A backing service (DB, auth) failed. This API fails closed, never returns 200 on an auth/secret error. |

## 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 tier;
contact ShelfAtlas to move to the partner tier.
