{
 "openapi": "3.1.0",
 "info": {
  "title": "MTB Deals",
  "version": "1",
  "summary": "Live mountain bike deals from 60 European, UK and US shops, with the real discount checked and a buy-or-wait verdict.",
  "description": "MTB Deals (https://mtbdeals.co/) reads the shops' own sale pages and product feeds every two hours and publishes the result as one JSON snapshot: every discounted bike, part and piece of gear with the shop's price next to the old one, the real cut against the pre-campaign price (the shop's own 30-day low where the page prints it), the frame sizes in stock, a price history and a buy-or-wait verdict with the facts behind it. The feed is public, read-only, GET-only, needs no key and is readable from any origin; it is updated every two hours (at the even UTC hours) and cached a minute in the browser and five minutes at the edge. Besides the feed this document names the HTML pages a crawler or an agent can read: one page per deal, the season's landing pages, the weekly market reports, the share cards and the text files for agents. Independent: no affiliate links, no sponsored placements, no trackers. Licence: CC BY 4.0, free to use and republish with the attribution “MTB Deals (mtbdeals.co)” and a link back.",
  "contact": {
   "name": "MTB Deals",
   "url": "https://mtbdeals.co/about/"
  },
  "license": {
   "name": "CC BY 4.0",
   "identifier": "CC-BY-4.0",
   "url": "https://creativecommons.org/licenses/by/4.0/"
  }
 },
 "servers": [
  {
   "url": "https://mtbdeals.co"
  }
 ],
 "externalDocs": {
  "description": "The long summary of the site for agents (llms-full.txt).",
  "url": "https://mtbdeals.co/llms-full.txt"
 },
 "tags": [
  {
   "name": "feed",
   "description": "The JSON snapshot."
  },
  {
   "name": "pages",
   "description": "Crawlable HTML pages rendered from the live snapshot."
  },
  {
   "name": "share",
   "description": "Share links and composed share cards."
  },
  {
   "name": "static",
   "description": "Pages and files from the build: no counts or dates from the snapshot."
  }
 ],
 "paths": {
  "/api/deals": {
   "get": {
    "tags": [
     "feed"
    ],
    "summary": "The snapshot: shop states, deals, market read, health.",
    "description": "The whole public state (about 2.3 MB raw, 300 KB compressed with gzip or brotli), written by the scan at every even UTC hour; `checkedAt` is the time of the scan it holds and `nextCheckAt` the next one. `?head=1` is the first page of the app's default list (48 deals: EU and UK shops, bikes, parts and gear, 10% off or more, best deals first) with the shop states and the market read, about 70 KB raw: what the app paints its first screen from.",
    "parameters": [
     {
      "name": "head",
      "in": "query",
      "description": "1 = the head: the first 48 deals of the default list instead of every product.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The snapshot (or its head).",
      "headers": {
       "Cache-Control": {
        "description": "public, max-age=60 (the edge holds it five minutes).",
        "schema": {
         "type": "string"
        }
       },
       "Access-Control-Allow-Origin": {
        "description": "*: readable from any origin.",
        "schema": {
         "type": "string"
        }
       }
      },
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Snapshot"
        }
       }
      }
     }
    }
   }
  },
  "/d/{id}": {
   "get": {
    "tags": [
     "share",
     "pages"
    ],
    "summary": "One deal as a page.",
    "description": "The product's Open Graph and Twitter tags for a link expander (the title with the price, the cut and the shop; the verdict as the description; the composed card as the image), a schema.org Product/Offer block, and the deal as a page for a person (photo, price, the recorded price points, the verdict and its reason, sizes in stock, the shop's own link, \"Open in the app\"). Indexable.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "description": "The product id (`<shop>:<key>`), URL-encoded.",
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The deal page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "302": {
      "description": "Unknown id: to the app."
     }
    }
   }
  },
  "/og/{id}.png": {
   "get": {
    "tags": [
     "share"
    ],
    "summary": "The composed share card of one deal.",
    "description": "1200×630 PNG: the photo on a white panel, the price, the discount badge, the verdict. Composed from the live snapshot, cached an hour.",
    "parameters": [
     {
      "name": "id",
      "in": "path",
      "required": true,
      "description": "The product id, URL-encoded.",
      "schema": {
       "type": "string"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The card.",
      "content": {
       "image/png": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "302": {
      "description": "Unknown id or a rendering failure: to the site's own card, og.png."
     }
    }
   }
  },
  "/og/list.png": {
   "get": {
    "tags": [
     "share"
    ],
    "summary": "The composed share card of a filtered list.",
    "description": "1200×630 PNG with the count and the top deals of the list the filters select (the same keys as #deals/<filters>). Cached an hour.",
    "parameters": [
     {
      "name": "shop",
      "in": "query",
      "description": "One shop id; the regions are then ignored.",
      "schema": {
       "type": "string",
       "enum": [
        "canyon",
        "rose",
        "radon",
        "specialized",
        "trek",
        "giant",
        "bikester",
        "12gobiking",
        "bike24",
        "bike-discount",
        "fahrrad-xxl",
        "mantel",
        "mount7",
        "bikeexchange",
        "decathlon",
        "buycycle",
        "tredz",
        "crc",
        "biketart",
        "gooutdoors",
        "tweeks",
        "autotrader",
        "sigma",
        "jenson",
        "evo",
        "backcountry",
        "competitive",
        "bikesonline",
        "ari",
        "bikemart",
        "mikesbikes",
        "rei",
        "bike-components",
        "fahrrad-de",
        "liquid-life",
        "bike-mailorder",
        "probikeshop",
        "propain",
        "yt",
        "thok",
        "bikepalast",
        "futurumshop",
        "merlin",
        "alltricks",
        "hibike",
        "bikeinn",
        "commencal",
        "cannondale",
        "orbea",
        "scott",
        "bmc",
        "kona",
        "stadler",
        "rabe",
        "boc24",
        "mammoth",
        "biketotaal",
        "nr1mtbshop",
        "purebike",
        "leisurelakes"
       ]
      }
     },
     {
      "name": "regions",
      "in": "query",
      "description": "Comma-separated shop regions (default EU,UK).",
      "schema": {
       "type": "string",
       "pattern": "^(EU|UK|US)(,(EU|UK|US))*$"
      }
     },
     {
      "name": "cats",
      "in": "query",
      "description": "Comma-separated categories (default mtb,emtb,parts,gear): mtb = MTB, emtb = e-MTB, parts = Parts, gear = Gear & apparel, gravel = Gravel & road, ebike = Other e-bikes, bike = Other bikes.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "brands",
      "in": "query",
      "description": "Comma-separated brand names.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "sizes",
      "in": "query",
      "description": "Comma-separated frame sizes: S, M, L, XL, XXL, 3XL.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "wheels",
      "in": "query",
      "description": "Comma-separated wheel sizes: 29, mx, 27.5, 26, 28, 24, 20, 18, 16, 14, 12.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "rides",
      "in": "query",
      "description": "Comma-separated riding types: xc, trail, enduro, dh, dirt.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "frames",
      "in": "query",
      "description": "Comma-separated frame types: ht, fs.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "sig",
      "in": "query",
      "description": "Comma-separated verdicts to show: buy, good, wait, early.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "max",
      "in": "query",
      "description": "The maximum price in euro.",
      "schema": {
       "type": "integer",
       "minimum": 0
      }
     },
     {
      "name": "min",
      "in": "query",
      "description": "The minimum percent off (default 10).",
      "schema": {
       "type": "integer",
       "minimum": 0,
       "maximum": 100
      }
     },
     {
      "name": "q",
      "in": "query",
      "description": "Search words (all must match the title or the shop).",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "camp",
      "in": "query",
      "description": "A campaign id: deals filed under it or at a shop where it is live.",
      "schema": {
       "type": "string",
       "enum": [
        "singles-day",
        "black-week",
        "black-friday",
        "stealth-sale",
        "cyber-monday",
        "cyber-week",
        "boxing-day",
        "winter-sale"
       ]
      }
     },
     {
      "name": "new",
      "in": "query",
      "description": "1 = only deals first seen in the last 48 hours.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "real",
      "in": "query",
      "description": "1 = only deals with a real cut against their pre-campaign price.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "strict",
      "in": "query",
      "description": "1 = hide deals whose listing does not state the selected size, wheel, riding or frame type.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "sort",
      "in": "query",
      "description": "The order: best (best deals), pct (biggest discount), save (biggest saving), popular (most opened), low (lowest price), high (highest price), new (newest first), real (real markdown), signal (buy now first).",
      "schema": {
       "type": "string",
       "enum": [
        "best",
        "pct",
        "save",
        "popular",
        "low",
        "high",
        "new",
        "real",
        "signal"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The card.",
      "content": {
       "image/png": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "302": {
      "description": "A rendering failure: to og.png."
     }
    }
   }
  },
  "/list": {
   "get": {
    "tags": [
     "share"
    ],
    "summary": "A filtered list as a link.",
    "description": "The count in the title, the top deals as the description, the list card as the image, a schema.org ItemList of the first ten; a person is sent on to the app at #deals/<filters>. noindex.",
    "parameters": [
     {
      "name": "shop",
      "in": "query",
      "description": "One shop id; the regions are then ignored.",
      "schema": {
       "type": "string",
       "enum": [
        "canyon",
        "rose",
        "radon",
        "specialized",
        "trek",
        "giant",
        "bikester",
        "12gobiking",
        "bike24",
        "bike-discount",
        "fahrrad-xxl",
        "mantel",
        "mount7",
        "bikeexchange",
        "decathlon",
        "buycycle",
        "tredz",
        "crc",
        "biketart",
        "gooutdoors",
        "tweeks",
        "autotrader",
        "sigma",
        "jenson",
        "evo",
        "backcountry",
        "competitive",
        "bikesonline",
        "ari",
        "bikemart",
        "mikesbikes",
        "rei",
        "bike-components",
        "fahrrad-de",
        "liquid-life",
        "bike-mailorder",
        "probikeshop",
        "propain",
        "yt",
        "thok",
        "bikepalast",
        "futurumshop",
        "merlin",
        "alltricks",
        "hibike",
        "bikeinn",
        "commencal",
        "cannondale",
        "orbea",
        "scott",
        "bmc",
        "kona",
        "stadler",
        "rabe",
        "boc24",
        "mammoth",
        "biketotaal",
        "nr1mtbshop",
        "purebike",
        "leisurelakes"
       ]
      }
     },
     {
      "name": "regions",
      "in": "query",
      "description": "Comma-separated shop regions (default EU,UK).",
      "schema": {
       "type": "string",
       "pattern": "^(EU|UK|US)(,(EU|UK|US))*$"
      }
     },
     {
      "name": "cats",
      "in": "query",
      "description": "Comma-separated categories (default mtb,emtb,parts,gear): mtb = MTB, emtb = e-MTB, parts = Parts, gear = Gear & apparel, gravel = Gravel & road, ebike = Other e-bikes, bike = Other bikes.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "brands",
      "in": "query",
      "description": "Comma-separated brand names.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "sizes",
      "in": "query",
      "description": "Comma-separated frame sizes: S, M, L, XL, XXL, 3XL.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "wheels",
      "in": "query",
      "description": "Comma-separated wheel sizes: 29, mx, 27.5, 26, 28, 24, 20, 18, 16, 14, 12.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "rides",
      "in": "query",
      "description": "Comma-separated riding types: xc, trail, enduro, dh, dirt.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "frames",
      "in": "query",
      "description": "Comma-separated frame types: ht, fs.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "sig",
      "in": "query",
      "description": "Comma-separated verdicts to show: buy, good, wait, early.",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "max",
      "in": "query",
      "description": "The maximum price in euro.",
      "schema": {
       "type": "integer",
       "minimum": 0
      }
     },
     {
      "name": "min",
      "in": "query",
      "description": "The minimum percent off (default 10).",
      "schema": {
       "type": "integer",
       "minimum": 0,
       "maximum": 100
      }
     },
     {
      "name": "q",
      "in": "query",
      "description": "Search words (all must match the title or the shop).",
      "schema": {
       "type": "string"
      }
     },
     {
      "name": "camp",
      "in": "query",
      "description": "A campaign id: deals filed under it or at a shop where it is live.",
      "schema": {
       "type": "string",
       "enum": [
        "singles-day",
        "black-week",
        "black-friday",
        "stealth-sale",
        "cyber-monday",
        "cyber-week",
        "boxing-day",
        "winter-sale"
       ]
      }
     },
     {
      "name": "new",
      "in": "query",
      "description": "1 = only deals first seen in the last 48 hours.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "real",
      "in": "query",
      "description": "1 = only deals with a real cut against their pre-campaign price.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "strict",
      "in": "query",
      "description": "1 = hide deals whose listing does not state the selected size, wheel, riding or frame type.",
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     },
     {
      "name": "sort",
      "in": "query",
      "description": "The order: best (best deals), pct (biggest discount), save (biggest saving), popular (most opened), low (lowest price), high (highest price), new (newest first), real (real markdown), signal (buy now first).",
      "schema": {
       "type": "string",
       "enum": [
        "best",
        "pct",
        "save",
        "popular",
        "low",
        "high",
        "new",
        "real",
        "signal"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The list page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/black-friday-mountainbike-2026/": {
   "get": {
    "summary": "The season's main page: Black Friday 2026 mountain bike deals.",
    "description": "The app's default list from the live snapshot: a two-sentence answer (the campaign's date, how many shops run a live campaign, how many deals are below their pre-campaign price, the median real cut), the first 48 deals as cards and as a schema.org ItemList, links to every shop, brand and category page.",
    "responses": {
     "200": {
      "description": "The page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/calendar/": {
   "get": {
    "summary": "The campaign calendar.",
    "description": "The key dates, the campaigns with their live counts, one row per shop with its window, status, code and earlier-year record; schema.org SaleEvents per shop with a known window.",
    "responses": {
     "200": {
      "description": "The page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/shop/{shop}/": {
   "get": {
    "tags": [
     "pages"
    ],
    "summary": "One shop's page.",
    "description": "The shop's live deals, its campaign dates, code, earlier-year discounts and research; a blocked shop shows the research only.",
    "parameters": [
     {
      "name": "shop",
      "in": "path",
      "required": true,
      "description": "The shop id.",
      "schema": {
       "type": "string",
       "enum": [
        "canyon",
        "rose",
        "radon",
        "specialized",
        "trek",
        "giant",
        "bikester",
        "12gobiking",
        "bike24",
        "bike-discount",
        "fahrrad-xxl",
        "mantel",
        "mount7",
        "bikeexchange",
        "decathlon",
        "buycycle",
        "tredz",
        "crc",
        "biketart",
        "gooutdoors",
        "tweeks",
        "autotrader",
        "sigma",
        "jenson",
        "evo",
        "backcountry",
        "competitive",
        "bikesonline",
        "ari",
        "bikemart",
        "mikesbikes",
        "rei",
        "bike-components",
        "fahrrad-de",
        "liquid-life",
        "bike-mailorder",
        "probikeshop",
        "propain",
        "yt",
        "thok",
        "bikepalast",
        "futurumshop",
        "merlin",
        "alltricks",
        "hibike",
        "bikeinn",
        "commencal",
        "cannondale",
        "orbea",
        "scott",
        "bmc",
        "kona",
        "stadler",
        "rabe",
        "boc24",
        "mammoth",
        "biketotaal",
        "nr1mtbshop",
        "purebike",
        "leisurelakes"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The shop page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "301": {
      "description": "Moved permanently: the same path with a trailing slash (/shop/{shop}/)."
     },
     "404": {
      "description": "No such page: the 404 page in the site's look (noindex)."
     }
    }
   }
  },
  "/brand/{slug}/": {
   "get": {
    "tags": [
     "pages"
    ],
    "summary": "One brand's page.",
    "description": "The default list cut to one brand (brands with three or more deals have a page; the slug is the brand name in lower case with hyphens, accents stripped).",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "pattern": "^[a-z0-9-]{1,60}$"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The brand page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "301": {
      "description": "Moved permanently: the same path with a trailing slash (/brand/{slug}/)."
     },
     "404": {
      "description": "No such page: the 404 page in the site's look (noindex)."
     }
    }
   }
  },
  "/category/{slug}/": {
   "get": {
    "tags": [
     "pages"
    ],
    "summary": "One category's page.",
    "description": "The deals of one category: mtb = MTB, e-mtb = e-MTB, gravel = Gravel & road, parts = Parts, gear = Gear & apparel.",
    "parameters": [
     {
      "name": "slug",
      "in": "path",
      "required": true,
      "schema": {
       "type": "string",
       "enum": [
        "mtb",
        "e-mtb",
        "gravel",
        "parts",
        "gear"
       ]
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The category page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "301": {
      "description": "Moved permanently: the same path with a trailing slash (/category/{slug}/)."
     },
     "404": {
      "description": "No such page: the 404 page in the site's look (noindex)."
     }
    }
   }
  },
  "/weekly/": {
   "get": {
    "summary": "The weekly market reports: the index.",
    "description": "The current week first (rendered live), then every sealed week, newest first.",
    "responses": {
     "200": {
      "description": "The page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/weekly/{week}/": {
   "get": {
    "tags": [
     "pages"
    ],
    "summary": "One week's market report.",
    "description": "From the same data as the Sunday digest mail: the default list's numbers (deals, how many below their pre-campaign price, the median real cut), the shops that went live that week, the biggest price drops and the best deals per group as a schema.org Article with an ItemList. The current week renders live from the snapshot; each week is sealed by the first scan from Sunday 08:00 UTC and kept. Weeks before the first report have no page.",
    "parameters": [
     {
      "name": "week",
      "in": "path",
      "required": true,
      "description": "An ISO 8601 week, e.g. 2026-W46.",
      "schema": {
       "type": "string",
       "pattern": "^\\d{4}-W\\d{2}$"
      }
     }
    ],
    "responses": {
     "200": {
      "description": "The week's report.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     },
     "301": {
      "description": "Moved permanently: the same path with a trailing slash (/weekly/{week}/)."
     },
     "404": {
      "description": "No such page: the 404 page in the site's look (noindex)."
     }
    }
   }
  },
  "/faq/": {
   "get": {
    "tags": [
     "static"
    ],
    "summary": "Questions about Black Friday bike deals (schema.org FAQPage).",
    "responses": {
     "200": {
      "description": "The FAQ.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/about/": {
   "get": {
    "tags": [
     "static"
    ],
    "summary": "What MTB Deals is, how it works, who makes it.",
    "responses": {
     "200": {
      "description": "The About page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/privacy/": {
   "get": {
    "tags": [
     "static"
    ],
    "summary": "What the browser stores, what the server records, email alerts, hosting.",
    "responses": {
     "200": {
      "description": "The Privacy page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/api/": {
   "get": {
    "tags": [
     "static"
    ],
    "summary": "This description for people, with a schema.org Dataset.",
    "responses": {
     "200": {
      "description": "The data page.",
      "content": {
       "text/html": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/sitemap.xml": {
   "get": {
    "tags": [
     "pages"
    ],
    "summary": "The sitemap, live from the snapshot.",
    "description": "The home page, the static pages, the season's landing pages, the weekly reports and the 500 biggest discounts as deal pages, each with the day of its last price change as lastmod.",
    "responses": {
     "200": {
      "description": "The sitemap.",
      "content": {
       "application/xml": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/llms.txt": {
   "get": {
    "tags": [
     "static"
    ],
    "summary": "The summary of the site for AI agents (llms.txt convention).",
    "responses": {
     "200": {
      "description": "Plain text.",
      "content": {
       "text/plain": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  },
  "/llms-full.txt": {
   "get": {
    "tags": [
     "static"
    ],
    "summary": "The long version: how the data is read, the verdicts, the feed field by field, every URL format.",
    "responses": {
     "200": {
      "description": "Plain text.",
      "content": {
       "text/plain": {
        "schema": {
         "type": "string"
        }
       }
      }
     }
    }
   }
  }
 },
 "components": {
  "schemas": {
   "Snapshot": {
    "type": "object",
    "description": "The public state the app reads: the shop states, the current deals, the market read and the health of the last scan. Written whole by every scan.",
    "required": [
     "v",
     "checkedAt",
     "shops",
     "products"
    ],
    "properties": {
     "v": {
      "type": "integer",
      "description": "The format version (1)."
     },
     "checkedAt": {
      "type": [
       "string",
       "null"
      ],
      "description": "When the scan that produced this snapshot ran (ISO 8601, UTC); null before the first scan.",
      "format": "date-time"
     },
     "nextCheckAt": {
      "type": "string",
      "description": "When the next scheduled scan runs.",
      "format": "date-time"
     },
     "ms": {
      "type": "integer",
      "description": "How long the scan took, in milliseconds."
     },
     "head": {
      "type": "boolean",
      "description": "Present and true on ?head=1: `products` is the first page of the default list only."
     },
     "shops": {
      "type": "object",
      "description": "One entry per shop id (see Product.shop for the ids).",
      "additionalProperties": {
       "$ref": "#/components/schemas/ShopState"
      }
     },
     "products": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Product"
      },
      "description": "The deals: every discounted product the shops' own pages and feeds showed at the last check."
     },
     "market": {
      "$ref": "#/components/schemas/Market"
     },
     "health": {
      "type": "object",
      "description": "The self-check of the last scan and benchmark run: shop and feed tallies with the dead feeds named, the benchmark's stats and ages."
     },
     "alerts": {
      "type": "boolean",
      "description": "Whether email alerts are available on this site."
     }
    }
   },
   "ShopState": {
    "type": "object",
    "description": "What the scanner saw at one shop: whether the read worked, the campaigns live or announced, the codes and snippets, the feeds.",
    "properties": {
     "ok": {
      "type": "boolean",
      "description": "The last read succeeded."
     },
     "stale": {
      "type": "boolean",
      "description": "The last read failed; the state and the products are from an earlier read."
     },
     "blocked": {
      "type": "boolean",
      "description": "The shop blocks automated reading: research only, no live deals."
     },
     "checkedAt": {
      "type": "string",
      "description": "When the shop was last read successfully.",
      "format": "date-time"
     },
     "tried": {
      "type": "string",
      "description": "When the last failed read was tried.",
      "format": "date-time"
     },
     "status": {
      "type": "integer",
      "description": "The HTTP status of the shop's page at the last read."
     },
     "error": {
      "type": "string",
      "description": "The last read's error, when it failed."
     },
     "live": {
      "type": "array",
      "items": {
       "type": "string",
       "enum": [
        "singles-day",
        "black-week",
        "black-friday",
        "stealth-sale",
        "cyber-monday",
        "cyber-week",
        "boxing-day",
        "winter-sale"
       ]
      },
      "description": "The campaigns the shop runs right now: only inside each campaign's window and only when the shop's own page or a campaign collection with discounted products shows it."
     },
     "announced": {
      "type": "array",
      "items": {
       "type": "string",
       "enum": [
        "singles-day",
        "black-week",
        "black-friday",
        "stealth-sale",
        "cyber-monday",
        "cyber-week",
        "boxing-day",
        "winter-sale"
       ]
      },
      "description": "The campaigns the shop mentions without running them yet."
     },
     "codes": {
      "type": "object",
      "additionalProperties": {
       "type": "string"
      },
      "description": "Checkout codes read off the page, per campaign id."
     },
     "snippets": {
      "type": "object",
      "additionalProperties": {
       "type": "string"
      },
      "description": "The sentence that revealed each campaign, per campaign id."
     },
     "maxPct": {
      "type": "number",
      "description": "The deepest discount the shop's page advertises."
     },
     "image": {
      "type": "string",
      "description": "The campaign banner or the shop's own share image as an absolute URL; an empty string when none was found."
     },
     "title": {
      "type": "string",
      "description": "The shop page's title."
     },
     "products": {
      "type": "integer",
      "description": "How many deals the shop contributed to the snapshot."
     },
     "feeds": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "url": {
         "type": "string",
         "description": "The feed's URL.",
         "format": "uri"
        },
        "ok": {
         "type": "boolean",
         "description": "The feed answered."
        },
        "n": {
         "type": "integer",
         "description": "Products read."
        },
        "campaign": {
         "type": [
          "string",
          "null"
         ],
         "description": "The campaign the feed files its products under."
        },
        "status": {
         "type": "integer",
         "description": "The HTTP status."
        },
        "ms": {
         "type": "integer",
         "description": "The read time in milliseconds."
        },
        "pages": {
         "type": "integer",
         "description": "Pages read, for a paged collection."
        },
        "partial": {
         "type": "boolean",
         "description": "The feed is a window of a bigger collection: a product seen only here is never counted as new."
        }
       }
      },
      "description": "The product feeds read for this shop."
     }
    }
   },
   "Product": {
    "type": "object",
    "description": "One deal, as the shop showed it at the last check. `ride`, `frame`, `wheels`, `sizes`, `sizesFrom`, `first`, `prev`, `h`, `hot`, `used` and `alt` are present only when known; `brand` is an empty string and `camp` is null when the shop states none.",
    "required": [
     "id",
     "shop",
     "title",
     "url",
     "price",
     "was",
     "cur",
     "pct",
     "cat"
    ],
    "properties": {
     "id": {
      "type": "string",
      "description": "`<shop>:<key>`: the id in share links (/d/<id>, URL-encoded) and in the app's #deal=<id> route."
     },
     "shop": {
      "type": "string",
      "enum": [
       "canyon",
       "rose",
       "radon",
       "specialized",
       "trek",
       "giant",
       "bikester",
       "12gobiking",
       "bike24",
       "bike-discount",
       "fahrrad-xxl",
       "mantel",
       "mount7",
       "bikeexchange",
       "decathlon",
       "buycycle",
       "tredz",
       "crc",
       "biketart",
       "gooutdoors",
       "tweeks",
       "autotrader",
       "sigma",
       "jenson",
       "evo",
       "backcountry",
       "competitive",
       "bikesonline",
       "ari",
       "bikemart",
       "mikesbikes",
       "rei",
       "bike-components",
       "fahrrad-de",
       "liquid-life",
       "bike-mailorder",
       "probikeshop",
       "propain",
       "yt",
       "thok",
       "bikepalast",
       "futurumshop",
       "merlin",
       "alltricks",
       "hibike",
       "bikeinn",
       "commencal",
       "cannondale",
       "orbea",
       "scott",
       "bmc",
       "kona",
       "stadler",
       "rabe",
       "boc24",
       "mammoth",
       "biketotaal",
       "nr1mtbshop",
       "purebike",
       "leisurelakes"
      ],
      "description": "The shop id (60 shops): canyon = Canyon (EU), rose = Rose Bikes (EU), radon = Radon (EU), specialized = Specialized (EU), trek = Trek (EU), giant = Giant (EU), bikester = Bikester.nl (EU), 12gobiking = 12GoBiking (EU), bike24 = Bike24 (EU), bike-discount = Bike-Discount (EU), fahrrad-xxl = Fahrrad XXL (EU), mantel = Mantel (EU), mount7 = Mount7 (EU), bikeexchange = BikeExchange (EU), decathlon = Decathlon (EU), buycycle = Buycycle (EU), tredz = Tredz (UK), crc = Chain Reaction / Wiggle (UK), biketart = Biketart (UK), gooutdoors = Go Outdoors (UK), tweeks = Tweeks Cycles (UK), autotrader = Auto Trader (e-MTB) (UK), sigma = Sigma Sports (UK), jenson = Jenson USA (US), evo = evo (US), backcountry = Backcountry (US), competitive = Competitive Cyclist (US), bikesonline = BikesOnline (US), ari = Ari Bikes (US), bikemart = Bikemart (US), mikesbikes = Mike's Bikes (US), rei = REI (US), bike-components = Bike-Components (EU), fahrrad-de = Fahrrad.de (EU), liquid-life = Liquid-Life (EU), bike-mailorder = Bike-Mailorder (EU), probikeshop = Probikeshop (EU), propain = Propain (EU), yt = YT Industries (EU), thok = Thok (EU), bikepalast = Bikepalast (EU), futurumshop = Futurumshop (EU), merlin = Merlin Cycles (UK), alltricks = Alltricks (EU), hibike = Hibike (EU), bikeinn = Bikeinn (Tradeinn) (EU), commencal = Commencal (EU), cannondale = Cannondale (EU), orbea = Orbea (EU), scott = Scott (EU), bmc = BMC (EU), kona = Kona (EU), stadler = Zweirad Stadler (EU), rabe = Rabe Bike (EU), boc24 = B.O.C. (EU), mammoth = Mammoth Bikes (EU), biketotaal = Bike Totaal / Profile (EU), nr1mtbshop = Nr1 MTB Shop (EU), purebike = Purebike (EU), leisurelakes = Leisure Lakes Bikes (UK)."
     },
     "title": {
      "type": "string",
      "description": "The product's name as the shop lists it."
     },
     "brand": {
      "type": "string",
      "description": "The brand as the listing states it; an empty string when it states none."
     },
     "url": {
      "type": "string",
      "description": "The shop's own product page: the place to buy. No affiliate or tracking parameters.",
      "format": "uri"
     },
     "img": {
      "type": "string",
      "description": "The shop's photo, a CDN base URL without size parameters; empty when there is none."
     },
     "price": {
      "type": "number",
      "description": "The price now, in `cur`."
     },
     "was": {
      "type": "number",
      "description": "The old price the shop shows next to it."
     },
     "cur": {
      "type": "string",
      "enum": [
       "EUR",
       "GBP",
       "USD"
      ],
      "description": "The currency of `price`, `was`, `low` and `prev`."
     },
     "pct": {
      "type": "integer",
      "description": "Percent off `was`, as the shop presents the discount."
     },
     "cat": {
      "type": "string",
      "enum": [
       "mtb",
       "emtb",
       "parts",
       "gear",
       "gravel",
       "ebike",
       "bike"
      ],
      "description": "The category: mtb = MTB, emtb = e-MTB, parts = Parts, gear = Gear & apparel, gravel = Gravel & road, ebike = Other e-bikes, bike = Other bikes."
     },
     "ride": {
      "type": "string",
      "enum": [
       "xc",
       "trail",
       "enduro",
       "dh",
       "dirt"
      ],
      "description": "The riding type read from the model name: xc = Cross-country, trail = Trail, enduro = Enduro, dh = Downhill, dirt = Dirt jump."
     },
     "frame": {
      "type": "string",
      "enum": [
       "ht",
       "fs"
      ],
      "description": "ht = Hardtail, fs = Full suspension."
     },
     "wheels": {
      "type": "array",
      "items": {
       "type": "string",
       "enum": [
        "29",
        "mx",
        "27.5",
        "26",
        "28",
        "24",
        "20",
        "18",
        "16",
        "14",
        "12"
       ]
      },
      "description": "Wheel sizes: 29 = 29″, mx = MX 29/27.5″, 27.5 = 27.5″, 26 = 26″, 28 = 28″ / 700C, 24 = 24″, 20 = 20″, 18 = 18″, 16 = 16″, 14 = 14″, 12 = 12″."
     },
     "sizes": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "The frame sizes in stock as the shop writes them (letters such as M or XL, but also 51 cm, 19\", EU 42 | L, S3): raw tokens, not one scale. The app's size filter folds them onto S–3XL; a reader of the feed must do the same."
     },
     "sizesFrom": {
      "type": "object",
      "description": "Where the sizes came from when not from the listing's own in-stock variants.",
      "properties": {
       "src": {
        "type": "string",
        "enum": [
         "page",
         "title",
         "feed"
        ],
        "description": "page = read from the product page (once a day); title = read from the product's name (a single-bike listing); feed = the listing's size options without stock per size (the WooCommerce shops), always with stock false."
       },
       "at": {
        "type": "string",
        "description": "The day the page was read.",
        "format": "date"
       },
       "stock": {
        "type": "boolean",
        "description": "The source stated stock per size."
       },
       "none": {
        "type": "boolean",
        "description": "The page listed sizes and none was in stock."
       }
      }
     },
     "camp": {
      "type": [
       "string",
       "null"
      ],
      "enum": [
       "singles-day",
       "black-week",
       "black-friday",
       "stealth-sale",
       "cyber-monday",
       "cyber-week",
       "boxing-day",
       "winter-sale",
       null
      ],
      "description": "The campaign the shop files this deal under, when a campaign collection or page named one; null otherwise (most products outside a campaign week)."
     },
     "first": {
      "type": "string",
      "description": "When the tracker first saw the product.",
      "format": "date-time"
     },
     "low": {
      "type": "number",
      "description": "The lowest price in the tracker's history for the product."
     },
     "prev": {
      "type": "number",
      "description": "The previous recorded price, when the price has moved at least once."
     },
     "h": {
      "type": "array",
      "items": {
       "type": "array",
       "prefixItems": [
        {
         "type": "string",
         "description": "The day (YYYY-MM-DD).",
         "format": "date"
        },
        {
         "type": "number",
         "description": "The price on that day."
        }
       ],
       "items": false,
       "minItems": 2,
       "maxItems": 2
      },
      "description": "Recent price points as [day, price] tuples, oldest first, once the price has moved at least once (the last eight)."
     },
     "hot": {
      "type": "integer",
      "description": "How often the deal was opened here in the last two days, when that is often (the Popular badge)."
     },
     "real": {
      "$ref": "#/components/schemas/Real"
     },
     "used": {
      "$ref": "#/components/schemas/Used"
     },
     "alt": {
      "$ref": "#/components/schemas/Alt"
     },
     "signal": {
      "$ref": "#/components/schemas/Signal"
     },
     "also": {
      "type": "array",
      "items": {
       "type": "object",
       "required": [
        "id",
        "shop",
        "price",
        "cur",
        "url"
       ],
       "properties": {
        "id": {
         "type": "string",
         "description": "The other offer's product id."
        },
        "shop": {
         "type": "string",
         "enum": [
          "canyon",
          "rose",
          "radon",
          "specialized",
          "trek",
          "giant",
          "bikester",
          "12gobiking",
          "bike24",
          "bike-discount",
          "fahrrad-xxl",
          "mantel",
          "mount7",
          "bikeexchange",
          "decathlon",
          "buycycle",
          "tredz",
          "crc",
          "biketart",
          "gooutdoors",
          "tweeks",
          "autotrader",
          "sigma",
          "jenson",
          "evo",
          "backcountry",
          "competitive",
          "bikesonline",
          "ari",
          "bikemart",
          "mikesbikes",
          "rei",
          "bike-components",
          "fahrrad-de",
          "liquid-life",
          "bike-mailorder",
          "probikeshop",
          "propain",
          "yt",
          "thok",
          "bikepalast",
          "futurumshop",
          "merlin",
          "alltricks",
          "hibike",
          "bikeinn",
          "commencal",
          "cannondale",
          "orbea",
          "scott",
          "bmc",
          "kona",
          "stadler",
          "rabe",
          "boc24",
          "mammoth",
          "biketotaal",
          "nr1mtbshop",
          "purebike",
          "leisurelakes"
         ]
        },
        "title": {
         "type": "string",
         "description": "Its title as that shop writes it (the sister shops title one item in two languages)."
        },
        "price": {
         "type": "number",
         "description": "Its price."
        },
        "cur": {
         "type": "string",
         "enum": [
          "EUR",
          "GBP",
          "USD"
         ]
        },
        "url": {
         "type": "string",
         "description": "Its product page.",
         "format": "uri"
        }
       }
      },
      "description": "Only in `?head=1`: the same item at a sister shop (Bikester.nl and Fahrrad.de sell one catalogue under two names), when the head's list folded the two offers into this row. The full feed lists every offer as its own product and never carries `also`."
     }
    }
   },
   "Real": {
    "type": "object",
    "description": "The markdown against the pre-campaign reference price: is the discount real?",
    "required": [
     "verdict"
    ],
    "properties": {
     "ref": {
      "type": "number",
      "description": "The reference price."
     },
     "src": {
      "type": "string",
      "enum": [
       "omnibus",
       "seen"
      ],
      "description": "omnibus = the shop's own lowest price of the previous 30 days, as EU shops must state; seen = the price when the tracker first saw the product."
     },
     "at": {
      "type": [
       "string",
       "null"
      ],
      "description": "The day the reference is from (for `new`, the day the tracker first saw the product); null when the shop's 30-day price carries no date.",
      "format": "date"
     },
     "pct": {
      "type": "integer",
      "description": "The cut against the reference in percent; negative when the price went up."
     },
     "verdict": {
      "type": "string",
      "enum": [
       "real",
       "same",
       "up",
       "new",
       "unknown"
      ],
      "description": "real = at least 5% under the reference price: a genuine new cut; same = within 5% of it: it cost that already (not a campaign cut, though it can still be a fair price); up = above it: the price rose since the reference; new = seen for the first time, under two days of history; unknown = no reference price yet."
     }
    }
   },
   "Signal": {
    "type": "object",
    "description": "The buy-or-wait verdict and the fact codes behind it (facts decide; earlier years are context; no predicted prices).",
    "required": [
     "v",
     "r"
    ],
    "properties": {
     "v": {
      "type": "string",
      "enum": [
       "buy",
       "good",
       "wait",
       "early"
      ],
      "description": "buy = \"Buy now\", good = \"Good price\", wait = \"Wait\", early = \"Too early to say\"."
     },
     "r": {
      "type": "array",
      "items": {
       "type": "string",
       "enum": [
        "up",
        "cheaper",
        "ahead",
        "early",
        "nohist",
        "real",
        "scarce",
        "cheapest",
        "used",
        "peak",
        "headroom",
        "low",
        "altAhead",
        "camp-price",
        "noshow",
        "peakdays",
        "late",
        "flat",
        "popular"
       ]
      },
      "description": "The fact codes, the headline first: up (the price rose since the reference), cheaper (another shop lists a comparable build for less), ahead (the shop's own campaign is announced and not started), early (under two days of history), nohist (no price history), real (a real cut), scarce (one size left), cheapest (the cheapest of the shops listing this model), used (close to second-hand asking prices), peak (at the level the shop reached in earlier years), headroom (the shop went deeper in earlier years), low (the lowest price in our history), altAhead (another shop selling this model line has a campaign ahead), camp-price (the shop's campaign price, unchanged), noshow (a quiet shop with a deep earlier-year record), peakdays (the Black Friday weekend), late (the campaigns are ending), flat (unchanged, nothing points to a cut), popular (opened often here)."
     }
    }
   },
   "Used": {
    "type": "object",
    "description": "Second-hand asking prices for the same model on Marktplaats, as context for the verdict (never a reason to wait on its own).",
    "properties": {
     "q": {
      "type": "string",
      "description": "The search words."
     },
     "n": {
      "type": "integer",
      "description": "Listings found."
     },
     "min": {
      "type": "number",
      "description": "The lowest asking price, in euro."
     },
     "med": {
      "type": "number",
      "description": "The median asking price, in euro."
     },
     "at": {
      "type": "string",
      "description": "The day of the search.",
      "format": "date"
     },
     "family": {
      "type": "boolean",
      "description": "The search fell back to the model family (the first two words)."
     }
    }
   },
   "Alt": {
    "type": "object",
    "description": "Other shops in the same region listing a comparable build (the same model line, RRP within ±12%).",
    "properties": {
     "n": {
      "type": "integer",
      "description": "How many other shops list a comparable build."
     },
     "shop": {
      "type": "string",
      "description": "The cheapest of them (a shop id)."
     },
     "price": {
      "type": "number",
      "description": "Its price."
     },
     "cur": {
      "type": "string",
      "description": "Its currency."
     },
     "d": {
      "type": "integer",
      "description": "Its distance from this price in percent: negative = cheaper there."
     },
     "ahead": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Shop ids selling this model line whose own campaign is announced and not started."
     }
    }
   },
   "Market": {
    "type": "object",
    "description": "The site-wide read (MTB and e-MTB only): where the season stands and what the numbers say.",
    "properties": {
     "day": {
      "type": "string",
      "description": "The day of the read.",
      "format": "date"
     },
     "phase": {
      "type": "string",
      "enum": [
       "early",
       "black-week",
       "black-friday",
       "weekend",
       "cyber-monday",
       "cyber-week",
       "after"
      ],
      "description": "The season's phase: early (before Black Week), black-week, black-friday, weekend, cyber-monday, cyber-week, after."
     },
     "shops": {
      "type": "integer",
      "description": "Shops tracked."
     },
     "live": {
      "type": "integer",
      "description": "Shops running a live campaign."
     },
     "ahead": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "shop": {
         "type": "string",
         "description": "The shop id."
        },
        "from": {
         "type": "string",
         "description": "The confirmed start.",
         "format": "date"
        }
       }
      },
      "description": "Shops with a confirmed window that has not started."
     },
     "bikes": {
      "type": "integer",
      "description": "Bikes in the snapshot."
     },
     "withRef": {
      "type": "integer",
      "description": "Bikes with a reference price."
     },
     "real": {
      "type": "integer",
      "description": "Bikes with a real cut."
     },
     "realMed": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The median real cut of those, in percent."
     },
     "pctMed": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The median discount against RRP, in percent."
     },
     "pctP75": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The 75th percentile of the discount against RRP."
     },
     "histMed": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The median of the shops' earlier-year peak discounts."
     },
     "histLo": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The lowest earlier-year peak."
     },
     "histHi": {
      "type": [
       "integer",
       "null"
      ],
      "description": "The highest earlier-year peak."
     },
     "drops48": {
      "type": "integer",
      "description": "Price drops recorded in the last 48 hours."
     },
     "new48": {
      "type": "integer",
      "description": "Deals first seen in the last 48 hours."
     },
     "histDays": {
      "type": "integer",
      "description": "Days of price history, counted from 2026-11-05."
     },
     "next": {
      "type": [
       "object",
       "null"
      ],
      "description": "The next dated campaign.",
      "properties": {
       "camp": {
        "type": "string",
        "enum": [
         "singles-day",
         "black-week",
         "black-friday",
         "stealth-sale",
         "cyber-monday",
         "cyber-week",
         "boxing-day",
         "winter-sale"
        ]
       },
       "date": {
        "type": "string",
        "description": "",
        "format": "date"
       },
       "days": {
        "type": "integer",
        "description": "Days to it."
       }
      }
     }
    }
   }
  }
 }
}