Search the 528k-row canonical product catalog without store context. Trigram text search ranked by KNN similarity, plus exact filters (UPC, product_id, brand, category). Returns one row per canonical product with full enrichment fields. The discovery layer above pricing — resolve a fuzzy query to a canonical product_id once, then drive everything else from that id.
/v1/products$0.01product_id UUIDs, then pass those into the price/coupon endpoints.The route accepts five filter modes, each tuned for a different workflow. Filter precedence when multiple are sent:
?ids=<uuid>,<uuid>,...?q=<text>brand or category to narrow oversampled candidates.?barcode=<upc>upc and zero-padded upc_normalized — pass whatever format you have. The path of choice when scanning a barcode.?product_id=<uuid>?brand=<name> + ?category=<name>| Name | Type | Description |
|---|---|---|
q | string | Trigram text search on name (and brand fallback). 1–100 characters. |
barcode | string | UPC or EAN. 6–32 digits. Matches both raw upc and zero-padded upc_normalized. |
product_id | uuid | Single product UUID lookup. |
ids | string | Comma-separated product UUIDs (max 50). When set, overrides every other filter and returns the requested rows in input order. |
brand | string | Exact brand match. Case-insensitive. |
category | string | Exact category match. Case-insensitive. |
limit | integerdefault: 50 | 1–500. |
offset | integerdefault: 0 | Cursor offset. |
422 Unprocessable Entity. The catalog has 528k+ rows; a bare SELECT * would be unfair to other tenants.curl 'https://api.mainmarket.com/v1/products?q=cheerios&limit=10'{
"count": 1,
"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",
"category": "Cereal",
"description": "Whole grain oats cereal.",
"ingredients": "Whole grain oats, corn starch, sugar, salt, ...",
"allergens": ["wheat"],
"dietary_labels": ["heart_healthy"],
"snap_eligible": true,
"serving_size": "39",
"serving_size_unit": "g"
}
]
}| Name | Type | Description |
|---|---|---|
product_id | uuid | Stable canonical product id. |
name | string | Canonical product name. |
brand | string | null | Manufacturer brand. Sparse for private label. |
size_display | string | null | Customer-facing size, e.g. '12 oz', '4 ct'. |
image_url | string | null | Public CDN image URL. |
upc | string | null | GS1 UPC, raw chain format. |
category | string | null | Internal category label. |
description | string | null | Long-form product description, when scraped. |
| Name | Type | Description |
|---|---|---|
ingredients | string | null | Free-text ingredient list as printed on the package. Sourced from chains that expose ingredients on PDPs (e.g. Wegmans, H-E-B, Whole Foods); null elsewhere. |
allergens | string[] | Allergen tags. Empty array (not null) when none are listed. Includes both clean tags ('wheat', 'milk') and raw label-disclosure phrases — clients should treat the array as an unordered set, not a precise contract. |
dietary_labels | string[] | Tags such as gluten_free, organic, vegan, kosher, heart_healthy. Empty array when none are listed. |
snap_eligible | boolean | null | True/false when known. Null when not yet classified — the column is sparsely populated today. |
serving_size, serving_size_unit | string | null | Serving size as printed (e.g. "39" + "g"). Combine client-side for display. |
import httpx
# Fast, ranked text search — "what does the user mean by 'cherrios'?"
r = httpx.get(
"https://api.mainmarket.com/v1/products",
params={"q": "cherrios", "limit": 5}, # typo intentional
headers={"Authorization": "Bearer mm_live_..."},
).json()
for p in r["results"]:
print(f" {p['name']} [{p.get('brand', '?')}] {p.get('size_display', '')}")
# Use the top result's UUID for follow-up queries (prices, coupons, etc.)
top = r["results"][0]
print(f"\nResolved → {top['product_id']}")The mass-lookup pattern. A Cart user's pantry might hold 30+ products by id; this hydrates them in one call instead of 30 round-trips.
import httpx
pantry_ids = ["9d4e1c80-...", "a1b2c3d4-...", "5e6f7g8h-..."] # up to 50
r = httpx.get(
"https://api.mainmarket.com/v1/products",
params={"ids": ",".join(pantry_ids)},
headers={"Authorization": "Bearer mm_live_..."},
).json()
# Note: order is NOT preserved; map by id if order matters.
by_id = {p["product_id"]: p for p in r["results"]}
for pid in pantry_ids:
p = by_id.get(pid)
if p is None:
print(f" {pid[:8]}... (not found)")
else:
print(f" {p['name']} {p.get('size_display', '')}")Pull every variant of a brand (every Cheerios SKU, every Heinz SKU). Useful for comparison shopping screens or building a brand catalog.
# Every Cheerios product
curl 'https://api.mainmarket.com/v1/products?brand=Cheerios&limit=50' \
-H "Authorization: Bearer mm_live_..."
# Narrow with a category
curl 'https://api.mainmarket.com/v1/products?brand=Cheerios&category=Cereal&limit=50' \
-H "Authorization: Bearer mm_live_..."q uses pg_trgm GIN-indexed similarity. Top matches are pulled from a 100-row name pool plus 100-row brand pool, then deduped — fast for short queries, deterministic for long ones. Sub-50ms p50 even on the full catalog.ids=...&q=cheerios, the q is ignored. This is a deliberate guardrail so the Pantry hydrate path can't accidentally fall into a slow text scan.ids= calls, results come back in DB-insert order, not your input order. Map by product_id if you need to preserve sequence.ingredients, allergens, dietary_labels, snap_eligible, and serving size are all populated by chain-specific PDP scrapes. Wegmans / H-E-B / Whole Foods tend to populate them; Kroger / Lidl typically don't. Treat as opportunistic enrichment, not a guarantee.Cache-Control: public, max-age=60, stale-while-revalidate=300. Honor that to cut cost.name ascending. Returns the canonical "every Brand X cereal" list deterministically.| Name | Type | Description |
|---|---|---|
422 | Unprocessable Entity | No filter supplied, or barcode outside 6–32 chars, or ids list >50 items. |
402 | Payment Required | Paid route — no payment proof. |
500 | Internal Server Error | Unexpected server fault. |