# 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).

## 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.

## 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) |

`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 — 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` |

Each group carries `key`, `title`, `item_key` and `product_id`. **`key` is
opaque** — `p:<product_id>` when the group resolved to a curated product,
else `k:<item_key>`. 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/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–90) | default `30`; look-back window |

`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.

### 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.

### 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/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/<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`).

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_<key>` (any tier) OR
an `X-Device-Token: <uuid>` 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_<key>`, **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 <key>"`
- 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.
