Deals & Promotions
Dispensary deals (percent-off sales, BOGOs, bundles, spend thresholds, and checkout discounts) are available through three tools on the CannMenus MCP server. The REST API has no deals endpoint. It shows shelf markdowns only, through original_price and latest_price.
Where deal data lives
| You need | Use |
|---|---|
| Whether a listing is marked down on the shelf | REST /v2/products: original_price is greater than latest_price |
| Deals a store, city, or state is running now | MCP retail_menu_deals |
| Products on special at one store, or across a city or brand within a state | MCP product_deals |
| Past deals: when they started and ended, how long they ran, weekday patterns | MCP deal_history (history begins 2026-07-15) |
Deals with discount_in_price: "cart" are applied at checkout. They are not reflected in listed prices: not in latest_price in the REST API, and not in current_price in MCP results. Show them as deal text, not as a lower price.
Shelf markdowns in the REST API
The Products endpoints return each listing's shelf price and, when the menu shows one, a compare-at price:
latest_priceis the listed shelf price. It does not include deals applied at checkout.original_priceis the compare-at price. Treat a listing as marked down only whenoriginal_priceis present and greater thanlatest_price; the two can be equal.- A
nulloriginal_pricemeans the menu shows no compare-at price. It does not mean the product has no deal: the store can still run a BOGO, bundle, or checkout discount that includes it. Useproduct_dealsto see those.
import os
import requests
resp = requests.get(
"https://api.cannmenus.com/v2/products",
headers={"X-Token": os.environ["CANNMENUS_API_TOKEN"]},
params={"states": "New Jersey", "recreational": "true", "category": "Flower", "per_page": 50},
timeout=60,
)
resp.raise_for_status()
for group in resp.json()["data"]:
for p in group["products"]:
original, latest = p.get("original_price"), p.get("latest_price")
if original is not None and latest is not None and original > latest:
print(f"{p['product_name']}: {latest} (was {original})")
The REST API returns the current listing only. For past promotions, use deal_history.
Calling deal tools from your backend
The MCP server accepts JSON-RPC requests over HTTP, so your backend can call a deal tool with a single POST. You don't need an AI client, an initialize handshake, or a session. Use the same API key you use for the REST API, sent as a Bearer token.
- URL:
https://api.cannmenus.com/mcp/ - Headers:
Authorization: Bearer YOUR_API_TOKEN,Content-Type: application/json, andAccept: application/json, text/event-stream. The server returns406ifAcceptdoesn't list both types. - Body: a JSON-RPC
tools/callrequest with the tool'snameandarguments.
curl -sN "https://api.cannmenus.com/mcp/" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"retail_menu_deals","arguments":{"state":"New Jersey","limit":5}}}'
The response body is a Server-Sent Events message: an event: message line, then a data: line that holds the JSON-RPC response. The tool's output is a JSON string in result.content[0].text, so you parse it a second time:
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"filters\": {\"states\": [\"New Jersey\"], \"include_inactive\": false}, \"rows\": [...], \"pagination\": {...}, \"notes\": {...}}"}],"structuredContent":{"result":"..."},"isError":false}}
result.structuredContent.result carries the same JSON string. result.isError is true when the call itself failed, for example an unknown tool name or an argument of the wrong type; content[0].text is then a plain-text message, not JSON.
Python example
This helper sends the request, reads the data: line, parses the tool output, and raises on each kind of error:
import json
import os
import requests
MCP_URL = "https://api.cannmenus.com/mcp/"
HEADERS = {
"Authorization": f"Bearer {os.environ['CANNMENUS_API_TOKEN']}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
def call_tool(name, arguments, timeout=90):
"""Call one CannMenus MCP tool and return its parsed JSON result."""
resp = requests.post(
MCP_URL,
headers=HEADERS,
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": name, "arguments": arguments},
},
timeout=timeout,
)
resp.raise_for_status() # 401: missing or invalid key. 429: rate limited, wait Retry-After.
# The body is a Server-Sent Events message: "event: message" then "data: {...}".
body = resp.content.decode("utf-8")
messages = [json.loads(line[len("data:"):]) for line in body.splitlines() if line.startswith("data:")]
message = next(m for m in messages if m.get("id") == 1)
if "error" in message: # JSON-RPC error
raise RuntimeError(message["error"])
result = message["result"]
text = result["content"][0]["text"]
if result.get("isError"): # e.g. unknown tool or invalid arguments
raise RuntimeError(text)
payload = json.loads(text)
if isinstance(payload, dict) and "error" in payload: # tool error, e.g. pagination_rate_limit
raise RuntimeError(payload)
return payload
deals = call_tool(
"retail_menu_deals",
{"state": "New Jersey", "menu_type": "recreational", "limit": 5},
)
for deal in deals["rows"]:
print(deal["retailer_name"], "|", deal["mechanic"], "|", deal["title"], "|", deal["discount_in_price"])
print("has_more:", deals["pagination"]["has_more"])
Keep your API key on your server. Don't call the MCP server or the REST API from a browser or mobile app with the key embedded in it.
Errors
| What you get | Meaning | What to do |
|---|---|---|
HTTP 401 | The API key is missing or invalid | Check the Authorization: Bearer header |
HTTP 400 | The body is not valid JSON or not a valid JSON-RPC message | Check the request body |
HTTP 406 | Accept doesn't list both application/json and text/event-stream | Send both types |
HTTP 415 | Content-Type is not application/json | Send Content-Type: application/json |
HTTP 429 | Your organization exceeded its rate limit, which REST and MCP calls share | Wait the number of seconds in Retry-After, then retry |
HTTP 200 with an error key in the JSON-RPC message | The server returned a JSON-RPC error object, for example for an unknown method | Check method and params in the request |
HTTP 200 with result.isError: true | Unknown tool name, or invalid arguments | Read the message in content[0].text and fix the call |
HTTP 200 with an error key in the parsed tool output | The tool refused or failed the request: for example missing_scope, state_required, brand_requires_state, dispensary_not_found, invalid_cursor, pagination_rate_limit, or a state outside your plan | Read error (and message, when present) and adjust the arguments |
The three deal tools
Pass arguments by name in params.arguments. Numbers in results can arrive as JSON strings (for example "current_price": "35.0" or "discount_value": "40.0"), so convert them before comparing. Timestamps are UTC strings with no offset, such as "2026-09-25 20:31:45.734513".
retail_menu_deals: deals running now
One row per deal on a dispensary menu, newest deal first.
- Name
state- Type
- string
- Description
Full state name, for example
"New Jersey". Recommended; pass it whenever you usebrand.
- Name
city- Type
- string
- Description
City or borough, case-insensitive. Matches the store's listed city or the city parsed from its address.
- Name
dispensary_name_or_id- Type
- string
- Description
A dispensary ID (digits) or a store name. Name matching is token-based: each word you pass must match.
- Name
brand- Type
- string
- Description
Brand name, case-insensitive substring. Returns deals whose attributed products carry the brand, plus deals that name it in their title or text. Pass
statewith it.
- Name
menu_type- Type
- string
- Description
medical,recreational, orboth.recreationalalso returns deals markedboth. For adult-use shoppers, pass"recreational".
- Name
mechanic- Type
- string
- Description
sale,bogo,bundle,spend_threshold,cart_discount,featured, orother.
- Name
scope- Type
- string
- Description
store(store-wide),group(a category or brand group), orproduct(specific products).
- Name
min_discount_pct- Type
- number
- Description
Minimum percent off. Filters percent-off sales only; BOGO, bundle, spend-threshold, cart-discount, and featured deals are returned regardless.
- Name
include_inactive- Type
- boolean
- Description
Include deals no longer seen on the menu. Default
false.
- Name
cursor- Type
- string
- Description
pagination.next_cursorfrom the previous page. Omit on the first call.
- Name
limit- Type
- integer
- Description
Rows per page. Default 50, maximum 200.
Parsed output (values are illustrative; field names are exact):
{
"filters": { "states": ["New Jersey"], "menu_type": "recreational", "include_inactive": false },
"rows": [
{
"deal_id": 123456,
"retailer_id": 7890,
"retailer_name": "Example Dispensary",
"city": "Newark",
"state": "New Jersey",
"menu_provider": "Dutchie",
"menu_type": "recreational",
"scope": "product",
"mechanic": "sale",
"applies_to": "explicit",
"title": "40% off select vapes",
"deal_text": null,
"promotion_type": "sale",
"discount_kind": "percent",
"discount_value": "40.0",
"discount_in_price": "shelf",
"starts_at": null,
"ends_at": null,
"is_active": true,
"first_seen_at": "2026-09-25 20:31:45.734513",
"last_seen_at": "2026-09-25 21:02:42.782328",
"attributed_product_count": 1,
"sample_products": [
{ "product_name": "Example Vape 1g", "current_price": 30.0, "original_price": 50.0, "role": "discounted" }
]
}
],
"pagination": { "mode": "cursor", "returned_count": 1, "has_more": true, "truncated": false, "next_cursor": "eyJwIj..." },
"notes": { "discount_in_price": "...", "min_discount_pct": "...", "attributed_product_count": "..." }
}
discount_in_priceisshelfwhen the discount is already in the product's shelf price, andcartwhen it is applied at checkout.discount_kindanddiscount_valuecan benull, as they are for many BOGOs.starts_atandends_atare the deal's advertised window when the menu publishes one. They are oftennull.attributed_product_countcounts the products the menu links to the deal. It is0for store-wide and group deals whose products the menu doesn't list.sample_productsshows up to five of them, ornull.
product_deals: products on special
product_deals has two modes.
One store: pass dispensary_name_or_id (optionally brand or product_search). The response has a top-level dispensary object (id, dispensary_name, city, state) and one row per product with product_id, product_name, brand_name, category, current_price, original_price, is_on_sale, promotion_text, promotion_type, active_deal_count, and deals. deals is an array of the deals linked to that product, or null when none is linked (for example, a plain shelf markdown), so treat null as an empty list (row.get("deals") or []). Each deals entry has deal_id, role, title, deal_text, mechanic, scope, menu_type, discount_kind, discount_value, discount_in_price, starts_at, and ends_at. If a store name matches more than one store, the response adds other_dispensary_matches; pass the ID of the store you want.
Across stores: omit dispensary_name_or_id and pass state plus city and/or brand. The response has "mode": "cross_retailer". Each row carries its store in retailer_id, retailer_name, city, and state, and has no deals array:
{
"mode": "cross_retailer",
"filters": { "states": ["New Jersey"], "city": "Newark" },
"rows": [
{
"product_id": 312345678,
"product_name": "Example Flower 3.5g",
"brand_name": "Example Brand",
"category": "Flower",
"current_price": "35.0",
"original_price": "50.0",
"promotion_text": "30% off",
"promotion_type": "sale",
"retailer_id": 7890,
"retailer_name": "Example Dispensary",
"city": "Newark",
"state": "New Jersey"
}
],
"pagination": { "mode": "cursor", "returned_count": 1, "has_more": true, "truncated": false, "next_cursor": "eyJwIj..." },
"notes": { "on_sale": "..." }
}
Rows in both modes are listings that the menu flags as on sale or that are linked to an active store deal. Before you show one to a shopper:
- Show a price markdown only when
original_priceis present and greater thancurrent_price. A linked deal, such as a BOGO applied at checkout, can leave the two prices equal. For the terms of those deals, use single-storeproduct_deals(thedealsarray) orretail_menu_deals. - Skip rows with an empty
product_name. Rows can include out-of-stock or unnamed listings.brand_namecan benullwhen the brand isn't matched to the CannMenus catalog. - Confirm availability on the dispensary's menu before telling a shopper a product is in stock.
deal_history: past deals
History begins 2026-07-15. There are no events before that date.
Scope each call with dispensary_name_or_id, or with state (optionally plus city and/or brand; pass state whenever you use brand). date_from and date_to are inclusive ISO dates. mode selects the output:
mode | Returns | Row fields |
|---|---|---|
timeline (default) | Lifecycle events, newest first: deal_started, deal_ended, deal_changed. Pass event_types: ["deal_observed"] to see raw menu observations. | event_id, changed_at, event_type, deal_id, title, mechanic, discount_kind, discount_value, promotion_text, deal_details, scope, retailer_id, retailer_name, city, state, menu_provider, menu_type, deal_is_active |
day_of_week | Deal starts by ISO weekday (1 = Monday) and mechanic. No pagination. | iso_dow, day, mechanic, deal_started_events, distinct_deals |
summary | One row per deal, most recent activity first. | deal_id, title, deal_text, mechanic, discount_kind, discount_value, scope, menu_type, retailer_id, retailer_name, city, state, first_seen, ended_at, days_active, times_restarted, status, first_event_at, last_event_at |
day_of_week and summary default to the last 90 days when you omit date_from. In summary, ended_at is null while a deal is active, status is active or ended, and first_seen can predate 2026-07-15.
States and access
- Use full state names, such as
"New Jersey", instatearguments. - Call
whoami(no arguments) to see what your key covers. Itscovered_statesfield lists your plan's states, or"all"for full-access plans, andentitlementsshows which tool sets you can use. - Make one call per question. Deals in Newark are one call:
retail_menu_dealswithstate: "New Jersey", city: "Newark". Don't list retailers and call once per store.
Pagination, limits, and latency
- Page size. The deal tools return 50 rows by default; pass
limitfor up to 200. Whenpagination.has_moreistrue, sendpagination.next_cursorback ascursorwith the same arguments as the first call. - Follow-up page cap. Calls that pass
cursorare capped at 50 per hour per API key per tool. The count resets at the start of each UTC hour. Over the cap, the tool returns apagination_rate_limiterror in its output (HTTP200, not429). Narrow your filters instead of retrying. - Rate limit. MCP tool calls are metered as API usage alongside REST calls and share your organization's rate limit. Limits are set per organization. Over the limit, you get HTTP
429with aRetry-Afterheader. - Latency. City-wide calls can take tens of seconds, cross-store
product_dealsin particular. Call the tools from your server, cache the results, and set client timeouts of 60 seconds or more. Don't call them on each shopper page view.
Freshness
Deal and menu data is refreshed from dispensary menus periodically throughout the day; no guaranteed refresh interval. Show shoppers when a deal was last seen (last_seen_at) and ask them to confirm on the dispensary's menu before they buy.
Next steps
- MCP Server Setup: the tool catalog and AI-client setup
- Products: price fields and filters in the REST API
- Errors: status codes and rate limiting
