Every active coupon that applies to one canonical product, across every chain we track. Matches both single-UPC coupons (1:1 link) and multi-UPC bundle coupons (m:n link), dedupes by coupon id, and sorts by savings descending. The 'should I render a deal badge on this product page?' query.
/v1/products/{product_id}/coupons$0.01product_id in hand (from a UPC scan, search result, or basket entry) and want to surface every active deal attached to it — independent of which chain or store the user shops at. For the inverse question ("what coupons are at this store right now?"), List coupons with a store_id filter is the right call.| Name | Type | Description |
|---|---|---|
product_idrequired | uuid | Canonical product id. |
| Name | Type | Description |
|---|---|---|
valid_on | date | ISO date YYYY-MM-DD. Defaults to today. |
limit | integerdefault: 50 | 1–200. |
offset | integerdefault: 0 | Cursor offset. |
curl 'https://api.mainmarket.com/v1/products/9d4e1c80-78a3-4a6d-9b1f-2cdb3d0c7e90/coupons'{
"count": 1,
"results": [
{
"id": "0bc5b1d4-9a25-4d70-8a82-2ab3f6f1d901",
"chain_id": "12d6...",
"chain_name": "HEB",
"chain_logo_url": "https://.../heb.png",
"store_id": null,
"product_id": "9d4e1c80-78a3-4a6d-9b1f-2cdb3d0c7e90",
"product_name": "Cheerios Cereal, 18 oz",
"product_brand": "Cheerios",
"product_image_url": "https://.../cheerios.jpg",
"discount_type": "dollar_off",
"discount_value": 1.50,
"min_purchase_qty": 2,
"description": "Save $1.50 on 2 boxes of Cheerios",
"fine_print": "Limit 4 per transaction",
"valid_from": "2026-04-29",
"valid_to": "2026-05-13",
"is_digital_only": true,
"requires_loyalty": false,
"is_clippable": true,
"savings_pct": 13.65,
"rating": "good",
"deal_price": 4.74,
"role": "buy"
}
]
}Each row uses the standard CouponRow shape — see List coupons for the full field reference. This page focuses on what's specific to the product-scoped flow.
Two database paths feed the result, then we union and dedupe by coupon id:
coupons.product_id directly. Used for coupons that target a specific UPC ("$1.50 off Cheerios 18oz"). One row per matching coupon.coupon_products.product_id — the join table that attaches multi-product coupons to each member product. Used for brand- family deals ("$1 off any Cheerios cereal"), bundle coupons ("spend $20 on cereal, save $5"), and BOGO coupons.import httpx
# User landed on a product detail page; pull every active coupon
product_id = "9d4e1c80-78a3-4a6d-9b1f-2cdb3d0c7e90"
r = httpx.get(
f"https://api.mainmarket.com/v1/products/{product_id}/coupons",
headers={"Authorization": "Bearer mm_live_..."},
).json()
if not r["results"]:
print("No active deals for this product")
else:
print(f"{len(r['results'])} deals available:")
for c in r["results"]:
# Skip BOGO get-legs (the freebie row); show only the buy-leg / single
if c.get("role") == "get":
continue
store_scope = c["chain_name"] if c["store_id"] is None else f"{c['chain_name']} (store-only)"
until = c["valid_to"] or "no expiry"
rating = c.get("rating", "?")
print(f" [{rating:>5}] {c['description']} — {store_scope}, exp {until}")For a multi-item shopping list, you typically want "🏷 deal" indicators next to items that have any active coupon. Fan out one call per product (cheap — most products have 0-2 coupons), or use List coupons with a chain filter and join client-side.
import httpx
basket = ["9d4e...", "a1b2...", "c3d4..."]
deals_for = {}
for pid in basket:
r = httpx.get(
f"https://api.mainmarket.com/v1/products/{pid}/coupons",
params={"limit": 5},
headers={"Authorization": "Bearer mm_live_..."},
).json()
deals_for[pid] = r.get("count", 0)
for pid, n in deals_for.items():
badge = f"🏷 {n}" if n else ""
print(f" {pid[:8]}... {badge}")savings_pct DESC primary, valid_to ASC tiebreaker (soonest-to-expire wins ties). Best-deal- first ordering matches the standard product-detail UI.role is get, the implied unit price is zero — it's the "free" half of a buy-one-get-one. Surface carefully in UI; pair with the matching buy row by coupon id, or filter role !== "get" to show only the qualifying side.chain_name tells you where it's redeemable.lat/lng — every active coupon attached to the product comes back regardless of geography. Use the chain_id on each row to filter to the user's home metro client-side.Coupon-to-product attachments change daily as new deals get ingested and old ones expire. The endpoint sends Cache-Control: public, max-age=300, stale-while-revalidate=600 — a 5-minute cache. Don't cache longer — coupons can flip between clipped/unclipped state during a single session.
| Name | Type | Description |
|---|---|---|
404 | Not Found | product_id does not exist. |
422 | Unprocessable Entity | Bad valid_on date format. |
402 | Payment Required | Paid route — no payment proof. |