Store-scoped pricing search with tiered fallback (specific → regional → chain). Joins canonical products against the latest store_product_prices materialization. Optional geo sort by user location.
/v1/prices$0.01q, barcode, product_id, or product_ids and at least one of store_id, store_ids, chain, metro, state, or near must be present. Unfiltered scans return 422 Unprocessable Entity.| Name | Type | Description |
|---|---|---|
q | string | Trigram text search on product name + brand. 1–100 characters. |
barcode | string | UPC or EAN. 6–32 digits. Matches both raw upc and zero-padded upc_normalized. |
product_id | uuid | Single canonical product UUID. |
product_ids | string | Comma-separated canonical product UUIDs. |
| Name | Type | Description |
|---|---|---|
store_id | uuid | Single store UUID. Triggers tier resolution if the store has no own prices. |
store_ids | string | Comma-separated store UUIDs. Each one is resolved independently. |
chain | string | Chain slug filter, e.g. wegmans. |
metro | string | Metro key, e.g. nyc. |
state | string | Two-letter state code. |
near | string | Geo filter as lat,lng e.g. 40.6892,-73.9942. Filters source stores within radius_mi and sorts by distance ascending. |
radius_mi | numberdefault: 10 | Radius in miles for the near= filter. Range 0.1–50. Ignored when near is not set. |
| Name | Type | Description |
|---|---|---|
on_sale | boolean | When true, returns only rows with is_on_sale = true. |
normalize_aisles | booleandefault: false | When true, returns the aisle field with the same normalization applied by /v1/stores/{id}/aisles. Lets clients join price rows to the aisle picker without separate normalization. |
limit | integerdefault: 50 | 1–500. |
offset | integerdefault: 0 | Cursor offset. |
curl 'https://api.mainmarket.com/v1/prices?barcode=00016000275287&near=40.6892,-73.9942&radius_mi=5'{
"count": 2,
"results": [
{
"product_id": "9d4e1c80-78a3-4a6d-9b1f-2cdb3d0c7e90",
"name": "Cheerios Cereal, 18 oz",
"brand": "Cheerios",
"size_display": "18 oz",
"image_url": "https://.../cheerios.jpg",
"upc": "00016000275287",
"store_id": "8c1a4d1e-30a7-4d92-9e1c-1cb43c6f2e10",
"store_name": "Wegmans Brooklyn",
"chain_slug": "wegmans",
"price": 4.99,
"regular_price": 5.49,
"sale_price": 4.99,
"is_on_sale": true,
"unit_price": 0.28,
"unit_price_uom": "oz",
"aisle": "Aisle 4",
"section": "Cereal",
"available": true,
"source_store_id": "8c1a4d1e-30a7-4d92-9e1c-1cb43c6f2e10",
"source_store_name": "Wegmans Brooklyn",
"source_tier": "specific",
"source_distance_mi": 0.0,
"pricing_scope": "per_store",
"distance_mi": 0.42
},
{
"product_id": "9d4e1c80-78a3-4a6d-9b1f-2cdb3d0c7e90",
"name": "Cheerios Cereal, 18 oz",
"brand": "Cheerios",
"size_display": "18 oz",
"image_url": "https://.../cheerios.jpg",
"upc": "00016000275287",
"store_id": "ee44...",
"store_name": "HEB Houston Heights",
"chain_slug": "heb",
"price": 4.49,
"regular_price": 4.49,
"sale_price": null,
"is_on_sale": false,
"unit_price": 0.25,
"unit_price_uom": "oz",
"aisle": "Aisle 12",
"section": null,
"available": true,
"source_store_id": "ee44...",
"source_store_name": "HEB Houston Heights",
"source_tier": "regional",
"source_distance_mi": 8.7,
"pricing_scope": "per_store",
"distance_mi": null
}
]
}| Name | Type | Description |
|---|---|---|
product_id | uuid | Canonical product id. |
name, brand, size_display, image_url, upc | string | null | Canonical product fields. size_display falls back to spp.pack_size when the canonical column is null. |
store_id, store_name | string | The store the customer asked about (or the only matching store when no store_id was passed). |
chain_slug | string | null | Chain slug of the source store. |
price | number | null | Effective price the customer would actually pay (sale_price when on sale, else regular_price). |
regular_price, sale_price | number | null | Both rungs from the latest scrape. |
is_on_sale | boolean | True when sale_price < regular_price. |
unit_price | number | null | Price per unit_price_uom, when the chain exposes it. |
unit_price_uom | string | null | Unit, e.g. 'oz', 'lb', 'fl oz'. |
aisle, section | string | null | Where the product lives in the store. Aisle is normalized when normalize_aisles=true. |
available | boolean | Stock signal from the latest scrape. |
source_store_id, source_store_name | string | The store that actually supplied the price row. Differs from store_id when source_tier ≠ 'specific'. |
source_tier | string | One of specific, regional, chain. See "Tiered fallback" below. |
source_distance_mi | number | Miles between the requested store and the source store. 0.0 when source_tier='specific'. |
pricing_scope | string | null | Chain-level pricing model: per_store (each store has its own prices), chain_level (one price across the chain), or no_ecommerce (we have no online price source). |
distance_mi | number | null | User-to-source-store miles. Populated only when ?near= was supplied. Distinct from source_distance_mi. |
When you ask for prices at a store that hasn't been scraped (or that belongs to a chain with chain-wide pricing), the API resolves the request to a sibling store in the same chain. The source_tier field tells you which path was taken:
specificsource_distance_mi = 0. Surface without caveats.regionalchainchain_level pricing scopes; for per_store chains this is a directional estimate, not a guarantee.The fallback is computed at write-time in store_price_source and never crosses chain boundaries. When no store filter is given, every row is labeled specific with zero distance. See Concepts → Pricing tiers and freshness for guidance on when to disclose non-specific tiers in your UI.
?near= set: results sort by distance_mi ASC, then price ASC. Product detail "Nearby Prices" wants closest-then-cheapest.?q= set without ?near=: results sort by trigram relevance.Responses send Cache-Control: public, max-age=30, stale-while-revalidate=120. Prices change roughly hourly; the short TTL keeps typeahead snappy without showing stale rungs.
| Name | Type | Description |
|---|---|---|
422 | Unprocessable Entity | Missing product or store filter, malformed near=, lat/lng out of range, or radius_mi out of 0.1–50. |
402 | Payment Required | Paid route — no payment proof. |
500 | Internal Server Error | Unexpected server fault. |