{"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://coffeeaddict.nz/api/schema.json", "title": "Coffee Addict API", "description": "Prices, weights and stock are the roasters' own, read from their public shop pages and re-checked every night. We do not set them, mark them up, or estimate them: a figure we could not read is null. Free to quote with attribution and a link.", "$defs": {"bean": {"type": "object", "title": "Bean", "description": "One bag of coffee.", "additionalProperties": false, "required": ["id", "roaster", "roaster_slug", "roaster_url", "name", "url", "shop_url", "page", "price_nzd", "weight_g", "price_per_100g", "in_stock", "origin", "roast_style", "roast_level", "flavour_notes", "flavours", "shipping", "first_seen", "last_seen", "last_price_change", "previous_price_nzd"], "properties": {"id": {"type": "string", "description": "Stable id, `<roaster-slug>:<product>-<weight>`. Survives price changes."}, "roaster": {"type": "string", "description": "The roaster's name, as they write it."}, "roaster_slug": {"type": "string", "description": "Their key here, and the last part of their page URL."}, "roaster_url": {"type": "string", "description": "Short link to their shop's home page, on our domain."}, "name": {"type": "string", "description": "The coffee's name, as the roaster writes it."}, "url": {"type": "string", "description": "Where to send a reader: a short link on our domain that redirects to the roaster's product page and counts the click."}, "shop_url": {"type": "string", "description": "The roaster's own product page, unredirected — so you can see exactly where `url` lands."}, "page": {"type": "string", "description": "Our page for this roaster, where this bag is listed."}, "price_nzd": {"type": ["number", "null"], "description": "Shelf price in NZD. Null when we could not read one."}, "weight_g": {"type": ["integer", "null"], "description": "Bag weight in grams. Null when the roaster does not state it."}, "price_per_100g": {"type": ["number", "null"], "description": "Shelf price per 100g. Null when price or weight is null."}, "in_stock": {"type": "boolean", "description": "Whether the roaster showed it as available at the last check."}, "origin": {"type": ["string", "null"], "description": "Country or region, where the roaster names one."}, "roast_style": {"type": ["string", "null"], "description": "`espresso`, `filter` or `omni`, from the roaster's own words."}, "roast_level": {"type": ["string", "null"], "description": "Light/medium/dark, only where the roaster states it."}, "flavour_notes": {"type": "array", "items": {"type": "string"}, "description": "The roaster's own tasting notes, verbatim."}, "flavours": {"type": "array", "items": {"type": "string"}, "description": "Those notes mapped onto our 18-key flavour vocabulary."}, "shipping": {"type": "object", "title": "Shipping", "description": "What a roaster charges to deliver, as they publish it.", "additionalProperties": false, "required": ["published", "free_over_nzd", "free_over_g", "flat_nzd", "flat_min_g", "flat_varies", "ships_free", "delivered_price_nzd", "delivered_price_exact", "source", "note", "last_verified"], "properties": {"published": {"type": "boolean", "description": "Whether the roaster publishes a rate outside checkout. Several do not."}, "free_over_nzd": {"type": ["number", "null"], "description": "Order total they ship free at, where they publish one."}, "free_over_g": {"type": ["number", "null"], "description": "Order WEIGHT they ship free at, for the roasters whose threshold is a weight rather than a basket total. Either threshold alone is enough."}, "flat_nzd": {"type": ["number", "null"], "description": "Their flat rate below that, where they publish one."}, "flat_min_g": {"type": ["number", "null"], "description": "Minimum order weight for `flat_nzd` to apply, where they set one. Below it their rate is unpublished — do not apply the flat rate to a lighter order."}, "flat_varies": {"type": "boolean", "description": "True where the rate depends on the island; `flat_nzd` is then the cheaper."}, "ships_free": {"type": "boolean", "description": "Whether this single bag already clears a free-shipping threshold of either kind — the basket total or the weight."}, "delivered_price_nzd": {"type": ["number", "null"], "description": "This bag at the door: price plus shipping. Null when the roaster publishes no rate this bag can use (unmet free-shipping threshold, under-threshold rate unpublished) — the door price is unknowable then, and null cannot be misquoted the way a 'floor' price was."}, "delivered_price_exact": {"type": "boolean", "description": "False when `delivered_price_nzd` is a FLOOR — their rate varies by island and we quote its cheapest. Never treat a floor as a price."}, "source": {"type": ["string", "null"], "description": "The roaster page a human read the terms off."}, "note": {"type": ["string", "null"], "description": "Their terms in their own words, where they publish any."}, "last_verified": {"type": ["string", "null"], "description": "The day a human last read that page. Nothing re-checks it."}}}, "first_seen": {"type": "string", "description": "ISO-8601 UTC. When WE first saw it, not when the roaster listed it."}, "last_seen": {"type": "string", "description": "ISO-8601 UTC. The last check that found it."}, "last_price_change": {"type": ["string", "null"], "description": "ISO-8601 UTC of the last price MOVE. Null means it has never moved since we started watching — not that we have no history."}, "previous_price_nzd": {"type": ["number", "null"], "description": "What it cost before that move. Null if it has never moved."}}}, "roaster": {"type": "object", "title": "Roaster", "description": "One roaster.", "additionalProperties": false, "required": ["slug", "name", "url", "shop_url", "page", "bean_count", "coffee_count", "cheapest_per_100g", "last_checked", "ok", "stale", "city", "place", "lat", "lng", "location_source", "shipping", "tracked"], "properties": {"slug": {"type": "string", "description": "Their key here."}, "name": {"type": "string", "description": "Their name."}, "url": {"type": "string", "description": "Short link to their shop, on our domain (redirects, counts)."}, "shop_url": {"type": "string", "description": "Their own URL, unredirected."}, "page": {"type": "string", "description": "Our page for them."}, "bean_count": {"type": "integer", "description": "Bags of theirs we are tracking. One per size, so a coffee sold in three sizes counts three."}, "coffee_count": {"type": "integer", "description": "Distinct coffees behind those bags, sizes collapsed. Never more than `bean_count`."}, "cheapest_per_100g": {"type": ["number", "null"], "description": "Their cheapest bag per 100g."}, "last_checked": {"type": ["string", "null"], "description": "ISO-8601 UTC of our last successful read of their shop."}, "ok": {"type": "boolean", "description": "Whether that last read succeeded."}, "stale": {"type": "boolean", "description": "True when we have not managed to check them for 3 days."}, "city": {"type": ["string", "null"], "description": "Where they roast, where we have confirmed it."}, "place": {"type": ["string", "null"], "description": "Suburb and city, as one line."}, "lat": {"type": ["number", "null"], "description": "Latitude, for distance sorting. Null where we could not confirm one."}, "lng": {"type": ["number", "null"], "description": "Longitude."}, "location_source": {"type": ["string", "null"], "description": "The page a human read the address off."}, "shipping": {"type": "object", "title": "Terms", "description": "A roaster's published shipping terms.", "additionalProperties": false, "required": ["published", "free_over_nzd", "free_over_g", "flat_nzd", "flat_min_g", "flat_varies", "source", "note", "last_verified"], "properties": {"published": {"type": "boolean", "description": "Whether they publish a rate outside checkout. Several roasters do not."}, "free_over_nzd": {"type": ["number", "null"], "description": "Order total they ship free at, where they publish one."}, "free_over_g": {"type": ["number", "null"], "description": "Order WEIGHT they ship free at, for the roasters whose threshold is a weight rather than a basket total. Either threshold alone is enough."}, "flat_nzd": {"type": ["number", "null"], "description": "Their flat rate below that, where they publish one."}, "flat_min_g": {"type": ["number", "null"], "description": "Minimum order weight for `flat_nzd` to apply, where they set one. Below it their rate is unpublished — do not apply the flat rate to a lighter order."}, "flat_varies": {"type": "boolean", "description": "True where the rate depends on the island; `flat_nzd` is then the cheaper."}, "source": {"type": ["string", "null"], "description": "The roaster page a human read the terms off."}, "note": {"type": ["string", "null"], "description": "Their terms in their own words, where they publish any."}, "last_verified": {"type": ["string", "null"], "description": "The day a human last read that page. Nothing re-checks it."}}}, "tracked": {"type": "boolean", "description": "Always true in `roasters`: we read this roaster's shop every night. The `listed` array is the roasters we know of and do not price-track, and every row there is false."}}}, "deal": {"type": "object", "title": "Deal", "description": "A bag, with the reason it is on this list.", "additionalProperties": false, "required": ["id", "roaster", "roaster_slug", "roaster_url", "name", "url", "shop_url", "page", "price_nzd", "weight_g", "price_per_100g", "in_stock", "origin", "roast_style", "roast_level", "flavour_notes", "flavours", "shipping", "first_seen", "last_seen", "last_price_change", "previous_price_nzd", "why"], "properties": {"id": {"type": "string", "description": "Stable id, `<roaster-slug>:<product>-<weight>`. Survives price changes."}, "roaster": {"type": "string", "description": "The roaster's name, as they write it."}, "roaster_slug": {"type": "string", "description": "Their key here, and the last part of their page URL."}, "roaster_url": {"type": "string", "description": "Short link to their shop's home page, on our domain."}, "name": {"type": "string", "description": "The coffee's name, as the roaster writes it."}, "url": {"type": "string", "description": "Where to send a reader: a short link on our domain that redirects to the roaster's product page and counts the click."}, "shop_url": {"type": "string", "description": "The roaster's own product page, unredirected — so you can see exactly where `url` lands."}, "page": {"type": "string", "description": "Our page for this roaster, where this bag is listed."}, "price_nzd": {"type": ["number", "null"], "description": "Shelf price in NZD. Null when we could not read one."}, "weight_g": {"type": ["integer", "null"], "description": "Bag weight in grams. Null when the roaster does not state it."}, "price_per_100g": {"type": ["number", "null"], "description": "Shelf price per 100g. Null when price or weight is null."}, "in_stock": {"type": "boolean", "description": "Whether the roaster showed it as available at the last check."}, "origin": {"type": ["string", "null"], "description": "Country or region, where the roaster names one."}, "roast_style": {"type": ["string", "null"], "description": "`espresso`, `filter` or `omni`, from the roaster's own words."}, "roast_level": {"type": ["string", "null"], "description": "Light/medium/dark, only where the roaster states it."}, "flavour_notes": {"type": "array", "items": {"type": "string"}, "description": "The roaster's own tasting notes, verbatim."}, "flavours": {"type": "array", "items": {"type": "string"}, "description": "Those notes mapped onto our 18-key flavour vocabulary."}, "shipping": {"type": "object", "title": "Shipping", "description": "What a roaster charges to deliver, as they publish it.", "additionalProperties": false, "required": ["published", "free_over_nzd", "free_over_g", "flat_nzd", "flat_min_g", "flat_varies", "ships_free", "delivered_price_nzd", "delivered_price_exact", "source", "note", "last_verified"], "properties": {"published": {"type": "boolean", "description": "Whether the roaster publishes a rate outside checkout. Several do not."}, "free_over_nzd": {"type": ["number", "null"], "description": "Order total they ship free at, where they publish one."}, "free_over_g": {"type": ["number", "null"], "description": "Order WEIGHT they ship free at, for the roasters whose threshold is a weight rather than a basket total. Either threshold alone is enough."}, "flat_nzd": {"type": ["number", "null"], "description": "Their flat rate below that, where they publish one."}, "flat_min_g": {"type": ["number", "null"], "description": "Minimum order weight for `flat_nzd` to apply, where they set one. Below it their rate is unpublished — do not apply the flat rate to a lighter order."}, "flat_varies": {"type": "boolean", "description": "True where the rate depends on the island; `flat_nzd` is then the cheaper."}, "ships_free": {"type": "boolean", "description": "Whether this single bag already clears a free-shipping threshold of either kind — the basket total or the weight."}, "delivered_price_nzd": {"type": ["number", "null"], "description": "This bag at the door: price plus shipping. Null when the roaster publishes no rate this bag can use (unmet free-shipping threshold, under-threshold rate unpublished) — the door price is unknowable then, and null cannot be misquoted the way a 'floor' price was."}, "delivered_price_exact": {"type": "boolean", "description": "False when `delivered_price_nzd` is a FLOOR — their rate varies by island and we quote its cheapest. Never treat a floor as a price."}, "source": {"type": ["string", "null"], "description": "The roaster page a human read the terms off."}, "note": {"type": ["string", "null"], "description": "Their terms in their own words, where they publish any."}, "last_verified": {"type": ["string", "null"], "description": "The day a human last read that page. Nothing re-checks it."}}}, "first_seen": {"type": "string", "description": "ISO-8601 UTC. When WE first saw it, not when the roaster listed it."}, "last_seen": {"type": "string", "description": "ISO-8601 UTC. The last check that found it."}, "last_price_change": {"type": ["string", "null"], "description": "ISO-8601 UTC of the last price MOVE. Null means it has never moved since we started watching — not that we have no history."}, "previous_price_nzd": {"type": ["number", "null"], "description": "What it cost before that move. Null if it has never moved."}, "why": {"type": "string", "description": "Why this bag is on this list, in a sentence."}, "drop_percent": {"type": "number", "description": "Size of the price cut, where there was one."}}}, "shipping": {"type": "object", "title": "Shipping", "description": "What a roaster charges to deliver, as they publish it.", "additionalProperties": false, "required": ["published", "free_over_nzd", "free_over_g", "flat_nzd", "flat_min_g", "flat_varies", "ships_free", "delivered_price_nzd", "delivered_price_exact", "source", "note", "last_verified"], "properties": {"published": {"type": "boolean", "description": "Whether the roaster publishes a rate outside checkout. Several do not."}, "free_over_nzd": {"type": ["number", "null"], "description": "Order total they ship free at, where they publish one."}, "free_over_g": {"type": ["number", "null"], "description": "Order WEIGHT they ship free at, for the roasters whose threshold is a weight rather than a basket total. Either threshold alone is enough."}, "flat_nzd": {"type": ["number", "null"], "description": "Their flat rate below that, where they publish one."}, "flat_min_g": {"type": ["number", "null"], "description": "Minimum order weight for `flat_nzd` to apply, where they set one. Below it their rate is unpublished — do not apply the flat rate to a lighter order."}, "flat_varies": {"type": "boolean", "description": "True where the rate depends on the island; `flat_nzd` is then the cheaper."}, "ships_free": {"type": "boolean", "description": "Whether this single bag already clears a free-shipping threshold of either kind — the basket total or the weight."}, "delivered_price_nzd": {"type": ["number", "null"], "description": "This bag at the door: price plus shipping. Null when the roaster publishes no rate this bag can use (unmet free-shipping threshold, under-threshold rate unpublished) — the door price is unknowable then, and null cannot be misquoted the way a 'floor' price was."}, "delivered_price_exact": {"type": "boolean", "description": "False when `delivered_price_nzd` is a FLOOR — their rate varies by island and we quote its cheapest. Never treat a floor as a price."}, "source": {"type": ["string", "null"], "description": "The roaster page a human read the terms off."}, "note": {"type": ["string", "null"], "description": "Their terms in their own words, where they publish any."}, "last_verified": {"type": ["string", "null"], "description": "The day a human last read that page. Nothing re-checks it."}}}, "terms": {"type": "object", "title": "Terms", "description": "A roaster's published shipping terms.", "additionalProperties": false, "required": ["published", "free_over_nzd", "free_over_g", "flat_nzd", "flat_min_g", "flat_varies", "source", "note", "last_verified"], "properties": {"published": {"type": "boolean", "description": "Whether they publish a rate outside checkout. Several roasters do not."}, "free_over_nzd": {"type": ["number", "null"], "description": "Order total they ship free at, where they publish one."}, "free_over_g": {"type": ["number", "null"], "description": "Order WEIGHT they ship free at, for the roasters whose threshold is a weight rather than a basket total. Either threshold alone is enough."}, "flat_nzd": {"type": ["number", "null"], "description": "Their flat rate below that, where they publish one."}, "flat_min_g": {"type": ["number", "null"], "description": "Minimum order weight for `flat_nzd` to apply, where they set one. Below it their rate is unpublished — do not apply the flat rate to a lighter order."}, "flat_varies": {"type": "boolean", "description": "True where the rate depends on the island; `flat_nzd` is then the cheaper."}, "source": {"type": ["string", "null"], "description": "The roaster page a human read the terms off."}, "note": {"type": ["string", "null"], "description": "Their terms in their own words, where they publish any."}, "last_verified": {"type": ["string", "null"], "description": "The day a human last read that page. Nothing re-checks it."}}}, "subscription": {"type": "object", "title": "Subscription", "description": "One roaster's cheapest subscription at one bag size, priced to the door.", "additionalProperties": false, "required": ["roaster_slug", "roaster", "url", "shop_url", "page", "bag_size_g", "bag", "price_nzd", "price_from", "delivery_nzd", "delivery_published", "delivery_rule", "delivered_nzd", "delivered_exact", "delivered_per_100g", "discount_percent", "every_days", "kind", "choose_coffee", "plan_count", "commitment", "term_total_nzd", "term_deliveries", "house"], "properties": {"roaster_slug": {"type": "string", "description": "Their key here, and the last part of their page URL."}, "roaster": {"type": "string", "description": "The roaster's name, as they write it."}, "url": {"type": "string", "description": "Where to send a reader: a short link on our domain that redirects to the roaster's subscription listing and counts the click."}, "shop_url": {"type": "string", "description": "The roaster's own listing, unredirected — so you can see exactly where `url` lands."}, "page": {"type": "string", "description": "Our page for this roaster."}, "bag_size_g": {"type": "integer", "description": "The bag this row priced. Rows inside one size view can differ: five roasters sell the small bag as 200g and eight sell it as 250g, and neither is restated as the other."}, "bag": {"type": "string", "description": "That weight, written out."}, "price_nzd": {"type": "number", "description": "Coffee only, per delivery — the cheapest plan the roaster publishes at this size."}, "price_from": {"type": "boolean", "description": "True when their other coffees at this size cost more, so `price_nzd` is a floor rather than the price."}, "delivery_nzd": {"type": ["number", "null"], "description": "What freight costs ON THIS ORDER, not their general rate: 0 where the order already clears their free-shipping threshold. Null where they publish no rate that applies to it."}, "delivery_published": {"type": "boolean", "description": "Whether that figure came from a published rate."}, "delivery_rule": {"type": "string", "description": "The threshold or flat rate that decided it, in words."}, "delivered_nzd": {"type": "number", "description": "Coffee plus delivery: what a delivery actually costs."}, "delivered_exact": {"type": "boolean", "description": "False when `delivered_nzd` is a FLOOR — the roaster publishes no rate below their threshold, or their other coffees cost more. Never treat a floor as a price."}, "delivered_per_100g": {"type": "number", "description": "That total over the grams in the bag. This is what the comparison is ranked on."}, "discount_percent": {"type": ["number", "null"], "description": "Measured from the two published prices, never from an advertised percentage. 0 means the roaster sells the plan at shelf price — a real finding. Null means no comparable one-off price existed. The two are never merged."}, "every_days": {"type": "array", "items": {"type": "integer"}, "description": "Every delivery cadence offered at this size, in days. The per-delivery price does not move with it."}, "kind": {"type": "string", "description": "`subscribe_and_save` (an ordinary bag on repeat, published only at /products/<handle>.js) or `standalone` (the roaster's own subscription listing)."}, "choose_coffee": {"type": "boolean", "description": "Whether the plan lets the subscriber pick the coffee."}, "plan_count": {"type": "integer", "description": "How many plans the roaster publishes at this size."}, "commitment": {"type": "string", "description": "`prepaid` (you buy the whole term in advance), `minimum` (rolling, but a stated number of deliveries must arrive before you can cancel) or `rolling`. **`rolling` does NOT mean cancel anytime** — it means the roaster publishes nothing about a minimum term, and almost none of them do outside the checkout."}, "term_total_nzd": {"type": ["number", "null"], "description": "What a prepaid plan charges up front, for `deliveries` deliveries. Null on any other plan. This is the number that decides whether the offer is open to a reader at all, which is why prepaid plans are listed separately from the ranking rather than inside it."}, "term_deliveries": {"type": ["integer", "null"], "description": "How many deliveries that cheque covers, or the minimum a `minimum` plan ties you to. Null on a rolling plan."}, "house": {"type": "boolean", "description": "True only for a product of ours. False on every row today, and a house product is never ranked among the roasters' — see /subscriptions/."}}}, "listed_roaster": {"type": "object", "title": "ListedRoaster", "description": "A New Zealand roaster we know of and do not price-track.", "additionalProperties": false, "required": ["name", "url", "website", "town", "region", "tracked", "reason", "sells_online", "platform"], "properties": {"name": {"type": "string", "description": "Their name, as our sources write it."}, "url": {"type": ["string", "null"], "description": "Short link to their own site, on our domain (redirects, counts). Null where we have found no website for them."}, "website": {"type": ["string", "null"], "description": "Their own URL, unredirected. Null where we have none."}, "town": {"type": ["string", "null"], "description": "The town or suburb we have them in."}, "region": {"type": ["string", "null"], "description": "Their New Zealand region."}, "tracked": {"type": "boolean", "description": "Always false here. See `reason` for why."}, "reason": {"type": "string", "description": "Why we do not price-track them, in plain words: no website we could find, their site did not answer our check, their robots.txt asks us not to read it, their shop is password-protected, or simply not yet."}, "sells_online": {"type": ["boolean", "null"], "description": "Whether they appear to sell beans online at all. Null where we could not tell."}, "platform": {"type": ["string", "null"], "description": "What their shop is built on, where we could tell (`shopify`, `woocommerce`, `squarespace`, …). This is how we decide who is cheap to onboard next."}}}}}