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 needUse
Whether a listing is marked down on the shelfREST /v2/products: original_price is greater than latest_price
Deals a store, city, or state is running nowMCP retail_menu_deals
Products on special at one store, or across a city or brand within a stateMCP product_deals
Past deals: when they started and ended, how long they ran, weekday patternsMCP deal_history (history begins 2026-07-15)

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_price is the listed shelf price. It does not include deals applied at checkout.
  • original_price is the compare-at price. Treat a listing as marked down only when original_price is present and greater than latest_price; the two can be equal.
  • A null original_price means 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. Use product_deals to 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, and Accept: application/json, text/event-stream. The server returns 406 if Accept doesn't list both types.
  • Body: a JSON-RPC tools/call request with the tool's name and arguments.
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"])

Errors

What you getMeaningWhat to do
HTTP 401The API key is missing or invalidCheck the Authorization: Bearer header
HTTP 400The body is not valid JSON or not a valid JSON-RPC messageCheck the request body
HTTP 406Accept doesn't list both application/json and text/event-streamSend both types
HTTP 415Content-Type is not application/jsonSend Content-Type: application/json
HTTP 429Your organization exceeded its rate limit, which REST and MCP calls shareWait the number of seconds in Retry-After, then retry
HTTP 200 with an error key in the JSON-RPC messageThe server returned a JSON-RPC error object, for example for an unknown methodCheck method and params in the request
HTTP 200 with result.isError: trueUnknown tool name, or invalid argumentsRead the message in content[0].text and fix the call
HTTP 200 with an error key in the parsed tool outputThe 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 planRead 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 use brand.

  • 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 state with it.

  • Name
    menu_type
    Type
    string
    Description

    medical, recreational, or both. recreational also returns deals marked both. For adult-use shoppers, pass "recreational".

  • Name
    mechanic
    Type
    string
    Description

    sale, bogo, bundle, spend_threshold, cart_discount, featured, or other.

  • Name
    scope
    Type
    string
    Description

    store (store-wide), group (a category or brand group), or product (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_cursor from 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_price is shelf when the discount is already in the product's shelf price, and cart when it is applied at checkout.
  • discount_kind and discount_value can be null, as they are for many BOGOs.
  • starts_at and ends_at are the deal's advertised window when the menu publishes one. They are often null.
  • attributed_product_count counts the products the menu links to the deal. It is 0 for store-wide and group deals whose products the menu doesn't list. sample_products shows up to five of them, or null.

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_price is present and greater than current_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-store product_deals (the deals array) or retail_menu_deals.
  • Skip rows with an empty product_name. Rows can include out-of-stock or unnamed listings. brand_name can be null when 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:

modeReturnsRow 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_weekDeal starts by ISO weekday (1 = Monday) and mechanic. No pagination.iso_dow, day, mechanic, deal_started_events, distinct_deals
summaryOne 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", in state arguments.
  • Call whoami (no arguments) to see what your key covers. Its covered_states field lists your plan's states, or "all" for full-access plans, and entitlements shows which tool sets you can use.
  • Make one call per question. Deals in Newark are one call: retail_menu_deals with state: "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 limit for up to 200. When pagination.has_more is true, send pagination.next_cursor back as cursor with the same arguments as the first call.
  • Follow-up page cap. Calls that pass cursor are 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 a pagination_rate_limit error in its output (HTTP 200, not 429). 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 429 with a Retry-After header.
  • Latency. City-wide calls can take tens of seconds, cross-store product_deals in 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