shelfatlas-com Observations API

The Observations API from shelfatlas-com — 3 operation(s) for observations.

Operations 3

POST /api/v1/public/catalog/shelf-label/read Read a shelf label's price/name/EAN from a photo (SA-0165) #
POST /api/v1/observations Submit a shelf observation #
POST /api/v1/steering-events Record a steering render (partner-tier keys only) #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/shelfatlas-com:shelfatlas-com-observations-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

shelfatlas-com-observations-api-openapi.yml Raw ↑
openapi: 3.2.0
info:
  title: ShelfAtlas Catalog Observations API
  version: 1.0.0
  description: 'Read access to normalised Danish retail catalog data — products, offers, stores, and chains. Authenticate with a Bearer `sa_live_` API key. This document is the contract the official ShelfAtlas SDKs conform to. Data coverage: the offer feed covers every major Danish chain, but the curated product catalog is smaller and actively expanding — an empty data array from /products/{id}/offers or /price-history means no offers are linked to that product yet, not an error.'
  termsOfService: https://shelfatlas.com/legal/terms
servers:
- url: https://api.shelfatlas.com
  description: Production
security:
- bearerAuth: []
tags:
- name: Observations
paths:
  /api/v1/public/catalog/shelf-label/read:
    post:
      operationId: readShelfLabel
      summary: Read a shelf label's price/name/EAN from a photo (SA-0165)
      description: 'One vision call: uploads the given photo and reads the shelf-edge label''s name, price, EAN and stock hint, then returns catalog products matching what was read (the EAN if legible, otherwise a name search) as `candidates`. `image_key` is the uploaded photo''s Blob pathname — not yet accepted by `POST /api/v1/observations` (which has no image field), kept for a future observation-with-photo write. An unreadable label is still a 200: `reading.reason` is `unreadable` and `candidates` is empty — it consumed the vision call same as a readable one, it just has nothing to offer. Only an infra failure (Blob or the vision deployment unavailable) is a 503.'
      tags:
      - Observations
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShelfLabelReadRequest'
      responses:
        '200':
          description: The reading, any matching catalog candidates, and the uploaded photo key.
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - data
                additionalProperties: false
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  data:
                    $ref: '#/components/schemas/ShelfLabelReadResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /api/v1/observations:
    post:
      operationId: createObservation
      summary: Submit a shelf observation
      description: Records a crowd-sourced shelf signal — stock status, and optionally a price — for a product or offer at a store. Any API key (free or partner tier) may call this with the usual Bearer header; a caller without a key may submit anonymously with an X-Device-Token header instead (a client-generated UUID identifying the device — any UUID version is accepted, the check is purely the 8-4-4-4-12 shape). Sending both headers uses the API key. Contributing observations does not consume or grant API quota; this endpoint is rate-limited separately from the read endpoints (10 req/min per IP for anonymous and device-token callers, 60 req/min per API key for sa_live_ callers — SA-0037 —, plus 50 req/15min per IP and per device token for anonymous callers).
      tags:
      - Observations
      security:
      - bearerAuth: []
      - deviceTokenAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ObservationRequest'
      responses:
        '201':
          description: Observation recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObservationResult'
        '400':
          description: Malformed body — 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 (`invalid_body`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObservationError'
        '401':
          description: Neither an `Authorization` header nor an `X-Device-Token` header was sent (`missing_auth`); the API key is malformed/unrecognised or the device token is not a valid UUID (`invalid_auth`); or the API key was revoked (`revoked`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObservationError'
        '422':
          description: 'The referenced entity does not exist: no curated product matches `ean` (`unknown_ean`), no CatalogOffer matches `offer_id` (`unknown_offer`), or no store matches `store_id` (`unknown_store`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObservationError'
        '429':
          description: 'Two independent limiters can produce this: the shared per-IP write-route limit in apps/web/proxy.ts (10 req/min per IP for anonymous callers, 60 req/min per API key for sa_live_ callers, checked before this handler runs) and the handler''s own anonymous-caller check (50 req/15min, checked once per IP and once per device token, X-Device-Token callers only). Both return `rate_limit_exceeded` in the same { error, code, retryAfterSec } shape.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObservationError'
  /api/v1/steering-events:
    post:
      operationId: recordSteeringEvent
      summary: Record a steering render (partner-tier keys only)
      description: 'First-party telemetry: a partner''s consumer surface posts one row per candidate chain every time it localizes a "best choice" card, so the chosen store can be compared against the nearest store per candidate chain. Rolled up into daily, per-chain aggregates only — no per-request identifiers or coordinates are ever stored; `distance_band` is a client-computed bucket (''10''), never a raw lat/lng. Partner-tier API keys only (ShelfAtlas'' own consumer surfaces); any other tier gets `forbidden_tier`. Chain ids are resolved from the given store ids server-side — never accepted from the client.'
      tags:
      - Observations
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SteeringEventRequest'
      responses:
        '202':
          description: Steering rows recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SteeringEventResult'
        '400':
          description: 'Malformed body (`invalid_body`) — including a coordinate-like key (`lat`/`lng`/`lon`/`latitude`/`longitude`) anywhere in the payload, `involved` empty or over 25 entries, or an unrecognised field — or a referenced entity does not exist: no product matches `product_id` (`unknown_product`), or a store id in `winning_store_id`/`involved[].nearest_store_id` does not match any store (`unknown_store`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SteeringEventError'
        '401':
          description: No `Authorization` header (`missing_auth`); the API key is malformed/unrecognised (`invalid_auth`); or the key was revoked (`revoked`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SteeringEventError'
        '403':
          description: The API key is not partner-tier (`forbidden_tier`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SteeringEventError'
components:
  schemas:
    ShelfPhotoPrintedUnitPrice:
      type: object
      additionalProperties: false
      required:
      - value
      - per
      properties:
        value:
          type: number
          description: The label's own printed unit price, e.g. 10.73 for "1 L = 10,73".
        per:
          type: string
          enum:
          - l
          - kg
    ShelfLabelCandidate:
      type: object
      additionalProperties: false
      required:
      - ean
      - name
      properties:
        ean:
          type: string
        name:
          type:
          - string
          - 'null'
    ObservationError:
      type: object
      additionalProperties: false
      required:
      - error
      - code
      properties:
        error:
          type: string
          description: Human-readable message; always equals `code`.
        code:
          type: string
          enum:
          - missing_auth
          - invalid_auth
          - revoked
          - invalid_body
          - unknown_ean
          - unknown_offer
          - unknown_store
          - rate_limit_exceeded
          description: Lower_snake_case for every case, including `rate_limit_exceeded` from the shared apps/web/proxy.ts write-route limiter (10 req/min per IP anonymous, 60 req/min per API key, every /api/v1 write route, checked before this handler ever runs) — it follows the same { error, code (same value) } convention as the handler's own errors (SA-0031).
        retryAfterSec:
          type: integer
          description: Seconds to wait before retrying — present only on a rate-limit response.
    ShelfLabelReadRequest:
      type: object
      additionalProperties: false
      required:
      - image_base64
      - content_type
      properties:
        image_base64:
          type: string
          description: Base64-encoded image bytes (no `data:` URL prefix). Decoded size over 512 KB is a 413 — downscale client-side first.
        content_type:
          type: string
          enum:
          - image/jpeg
          - image/png
          - image/webp
        store_id:
          type: string
          format: uuid
          description: 'Optional (SA-0179): the store the photo was taken in. Lets an item with no product be matched to that store''s chain''s current campaign offer (`matched_by: offer`).'
    ShelfPhotoReadItem:
      type: object
      additionalProperties: false
      required:
      - name
      - brand
      - size_text
      - quantity_unit
      - volume_ml
      - unit_count
      - price_cents
      - spot
      - printed_unit_price
      - max_per_customer
      - ean
      - confidence
      - tier
      - known_product
      - candidates
      description: 'One item read from a shelf photo (SA-0179) — "same action as reading a leaflet page": ONE photo can return MANY items, each independently matched to the catalog.'
      properties:
        name:
          type: string
        brand:
          type:
          - string
          - 'null'
        size_text:
          type:
          - string
          - 'null'
        quantity_unit:
          type:
          - string
          - 'null'
          enum:
          - ml
          - g
          - pcs
          - null
        volume_ml:
          type:
          - number
          - 'null'
          description: ONE unit size, never the pack total.
        unit_count:
          type:
          - integer
          - 'null'
        price_cents:
          type:
          - integer
          - 'null'
          description: DKK×100. Null when no shelf-edge price label was legible for this item — never invented.
        spot:
          type: boolean
          description: True when the shelf label is marked as a spot/campaign price.
        printed_unit_price:
          anyOf:
          - $ref: '#/components/schemas/ShelfPhotoPrintedUnitPrice'
          - type: 'null'
        max_per_customer:
          type:
          - integer
          - 'null'
          description: A printed purchase cap ("Maks. N stk."), never a pack count.
        ean:
          type:
          - string
          - 'null'
        confidence:
          type: number
          description: 0.0–1.0 as judged by the vision model.
        tier:
          type: string
          enum:
          - sure
          - check
          description: '`sure` when ≥2 independent vision calls agreed on this item (pre-selected); `check` otherwise.'
        known_product:
          anyOf:
          - $ref: '#/components/schemas/ShelfLabelCandidate'
          - type: 'null'
        matched_by:
          type:
          - string
          - 'null'
          enum:
          - ean
          - auto
          - offer
          - null
          description: 'How the item was matched (SA-0179): its own EAN, the size-aware product auto-match, or the store''s chain''s current campaign offer (then `offer` is set and `known_product` is null).'
        offer:
          anyOf:
          - type: object
            additionalProperties: false
            required:
            - id
            - raw_name
            - price_cents
            properties:
              id:
                type: string
                format: uuid
              raw_name:
                type: string
              price_cents:
                type: integer
          - type: 'null'
        candidates:
          type: array
          items:
            $ref: '#/components/schemas/ShelfLabelCandidate'
    Error:
      type: object
      additionalProperties: true
      required:
      - error
      properties:
        error:
          type: string
          description: Machine-readable error code.
        message:
          type: string
        detail:
          type: string
        retry_after_sec:
          type: integer
          description: Seconds to wait before retrying (429).
        plan:
          type: string
          description: Caller's resolved plan id (present on monthly_limit_reached).
        limit:
          type: integer
          description: The plan monthly request quota (present on monthly_limit_reached).
        used:
          type: integer
          description: Requests already spent this UTC month (present on monthly_limit_reached).
        resets_at:
          type: string
          format: date-time
          description: When the quota resets — next UTC month start (present on monthly_limit_reached).
        next_plan:
          type: object
          nullable: true
          description: The cheapest plan with a larger quota, or null at the top of the ladder.
          properties:
            id:
              type: string
            name:
              type: string
            monthly_price_dkk:
              type: integer
            monthly_request_quota:
              type: integer
        plans_url:
          type: string
          format: uri
          description: Public pricing page — no sign-in required.
        upgrade_url:
          type: string
          format: uri
    ShelfLabelReadResult:
      type: object
      additionalProperties: false
      required:
      - reading
      - candidates
      - items
      - image_key
      properties:
        reading:
          $ref: '#/components/schemas/ShelfLabelReading'
        candidates:
          type: array
          items:
            $ref: '#/components/schemas/ShelfLabelCandidate'
        items:
          type: array
          items:
            $ref: '#/components/schemas/ShelfPhotoReadItem'
          description: SA-0179 slice 2 — every item the photo read, alongside the (first-priced-item) `reading`/`candidates` above kept for older clients.
        image_key:
          type: string
          description: The uploaded photo's Blob pathname — not yet accepted by `POST /api/v1/observations` (no image field there today); kept for a future observation-with-photo write.
    SteeringEventResult:
      type: object
      additionalProperties: false
      required:
      - ok
      - recorded
      properties:
        ok:
          type: boolean
          enum:
          - true
        recorded:
          type: integer
          description: Number of `involved` rows rolled up.
    ObservationRequest:
      type: object
      additionalProperties: false
      required:
      - store_id
      - stock_status
      description: Exactly one of `ean` or `offer_id` is required — the other is omitted.
      oneOf:
      - required:
        - ean
      - required:
        - offer_id
      properties:
        ean:
          type: string
          minLength: 1
          description: EAN/GTIN of a curated product. Required if offer_id is omitted.
        offer_id:
          type: string
          format: uuid
          description: A CatalogOffer id. Required if ean is omitted.
        store_id:
          type: string
          format: uuid
        stock_status:
          type: string
          enum:
          - in_stock
          - low_stock
          - out_of_stock
        price_cents:
          type: integer
          minimum: 1
          description: The observed price, in cents.
        currency:
          type: string
          minLength: 3
          maxLength: 3
          default: DKK
        consent_tier:
          type: string
          enum:
          - anonymous_aggregate
          - identifiable
          default: anonymous_aggregate
        reporter_clerk_id:
          type: string
          minLength: 1
          maxLength: 255
          description: Attributes the observation to an end-user your surface has already verified server-side. Honoured only for API-key auth.
        device_token:
          type: string
          format: uuid
          description: 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_at:
          type: string
          format: date-time
          description: When the shelf was actually seen, for a report submitted after the fact (e.g. a next-day follow-up) instead of the request's own "now". Must not be more than 5 minutes in the future (clock-skew allowance) or more than 48 hours in the past; omit to stamp the current time, unchanged from before this field existed.
    SteeringEventRequest:
      type: object
      additionalProperties: false
      required:
      - product_id
      - winning_store_id
      - involved
      properties:
        product_id:
          type: string
          format: uuid
        winning_store_id:
          type: string
          format: uuid
          description: The store the "best choice" card actually pointed to.
        visitor_key:
          type: string
          format: uuid
          description: Opaque per-device key the consumer surface generates and keeps locally, used only for the report's COUNT(DISTINCT visitor). Omit to skip unique-visitor counting for this render.
        involved:
          type: array
          minItems: 1
          maxItems: 25
          description: One entry per candidate chain compared for this render.
          items:
            type: object
            additionalProperties: false
            required:
            - nearest_store_id
            - distance_band
            - price_gap_cents
            properties:
              nearest_store_id:
                type: string
                format: uuid
                description: The candidate chain's store nearest the visitor.
              distance_band:
                type: string
                enum:
                - <1
                - 1-3
                - 3-10
                - '>10'
                description: Client-computed distance bucket, kilometres — never a raw coordinate.
              price_gap_cents:
                type: integer
                description: This candidate's price minus the winning store's, in cents.
    ObservationResult:
      type: object
      additionalProperties: false
      required:
      - id
      - moderation_status
      properties:
        id:
          type: string
          format: uuid
        moderation_status:
          type: string
          enum:
          - approved
          - pending_review
          description: A fresh observation auto-approves when it comes from a trusted source (a partner-tier API key, or a device with enough prior approved observations); otherwise it starts pending_review. 'rejected' is a later admin-moderation outcome — this endpoint never returns it on creation.
        top_reporter:
          type: object
          additionalProperties: true
          description: Present only when the request resolves to an identified reporter (via reporter_clerk_id or the API key's own owner). Shape may grow; treat any field not documented on the docs page as optional.
    SteeringEventError:
      type: object
      additionalProperties: false
      required:
      - error
      - code
      properties:
        error:
          type: string
          description: Equals `code`.
        code:
          type: string
          enum:
          - missing_auth
          - invalid_auth
          - revoked
          - forbidden_tier
          - invalid_body
          - unknown_store
          - unknown_product
    ShelfLabelReading:
      type: object
      additionalProperties: false
      required:
      - name
      - price_cents
      - ean
      - confidence
      - reason
      properties:
        name:
          type:
          - string
          - 'null'
        price_cents:
          type:
          - integer
          - 'null'
          description: DKK×100.
        ean:
          type:
          - string
          - 'null'
        confidence:
          type: number
          description: 0.0–1.0 as judged by the vision model.
        reason:
          type:
          - string
          - 'null'
          enum:
          - unreadable
          - null
          description: '`unreadable` when the label was read but not usable; null on a good read.'
  responses:
    RateLimited:
      description: Rate limit or monthly limit reached (`rate_limited` / `monthly_limit_reached`).
      headers:
        Retry-After:
          description: Seconds to wait before retrying (present on `rate_limited`).
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ServiceUnavailable:
      description: Temporary backend failure (`service_unavailable`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing, invalid, or revoked API key (`missing_api_key` / `invalid_api_key` / `revoked_api_key`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    BadRequest:
      description: Invalid parameters (`invalid_params`, `incompatible_params`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PayloadTooLarge:
      description: Decoded image exceeds the 512 KB cap (`payload_too_large`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'A ShelfAtlas API key: `Authorization: Bearer sa_live_<key>`.'
    deviceTokenAuth:
      type: apiKey
      in: header
      name: X-Device-Token
      description: 'Anonymous per-device identity for POST /api/v1/observations: a UUID the client generates and keeps locally. Any UUID version is accepted — the check is purely the 8-4-4-4-12 hex-digit shape.'