---
title: "Submit a shelf observation"
method: POST
path: "/api/v1/observations"
tags: ["Observations"]
---

# Submit a shelf observation

`POST /api/v1/observations`

## Request

- Base URL: `https://api.shelfatlas.com`
- URL: `https://api.shelfatlas.com/api/v1/observations`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-Device-Token`

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

## Request body

- union: Exactly one of `ean` or `offer_id` is required — the other is omitted.
  - object
    - `ean` string, required: EAN/GTIN of a curated product. Required if offer_id is omitted.
    - `offer_id` string, uuid: A CatalogOffer id. Required if ean is omitted.
    - `store_id` string, uuid, required
    - `stock_status` 'in_stock' | 'low_stock' | 'out_of_stock', required
    - `price_cents` integer: The observed price, in cents.
    - `currency` string
    - `consent_tier` 'anonymous_aggregate' | 'identifiable'
    - `reporter_clerk_id` string: Attributes the observation to an end-user your surface has already verified server-side. Honoured only for API-key auth.
    - `device_token` string, uuid: 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` string, date-time: 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.
  - object
    - `ean` string: EAN/GTIN of a curated product. Required if offer_id is omitted.
    - `offer_id` string, uuid, required: A CatalogOffer id. Required if ean is omitted.
    - `store_id` string, uuid, required
    - `stock_status` 'in_stock' | 'low_stock' | 'out_of_stock', required
    - `price_cents` integer: The observed price, in cents.
    - `currency` string
    - `consent_tier` 'anonymous_aggregate' | 'identifiable'
    - `reporter_clerk_id` string: Attributes the observation to an end-user your surface has already verified server-side. Honoured only for API-key auth.
    - `device_token` string, uuid: 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` string, date-time: 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.

## Response `201`

Observation recorded.

- ObservationResult
  - `id` string, uuid, required
  - `moderation_status` 'approved' | 'pending_review', required: 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` object: 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.

## Other responses

- `400`: 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`).
- `401`: 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`).
- `422`: 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`).
- `429`: 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.

---

[API](https://skmtc.dev/shelfatlas/apis/shelfatlas-catalog-api.md) · [All operations](https://skmtc.dev/shelfatlas/apis/shelfatlas-catalog-api/llms.txt) · [OpenAPI document](https://skmtc.dev/shelfatlas/apis/shelfatlas-catalog-api/revisions/943a8773856b?raw)
